Files
2026-07-24 22:36:04 +03:00

107 lines
4.4 KiB
Python

import json
import logging
from sqlalchemy import select
from app.database.models import Attachment
from app.database.repositories import MemoryRepository, PromptRepository
from app.llm.guardrails import wrap_untrusted
from app.memory.context_builder import ContextBuilder
logger = logging.getLogger(__name__)
class AnswerService:
def __init__(
self,
llm,
memory: MemoryRepository,
prompts: PromptRepository,
builder: ContextBuilder,
repository=None,
):
self.llm, self.memory, self.prompts, self.builder, self.repository = (
llm,
memory,
prompts,
builder,
repository,
)
async def answer(self, chat_id: int, thread_id: int | None, question: str) -> str:
summary = await self.memory.latest_summary(chat_id, thread_id)
messages = await self.memory.context_messages(chat_id, thread_id)
chat_context = self.builder.build(summary, messages)
attachments = await self.memory.session.scalars(
select(Attachment).where(Attachment.chat_id == chat_id, Attachment.accepted.is_(True))
)
attachment_context = "\n\n".join(
wrap_untrusted(f"attachment:{item.filename}", item.extracted_text or "")
for item in attachments
if item.extracted_text
)[:60_000]
custom = await self.prompts.active()
repo_context = await self._research(question)
prompt = (
f"User metaprompt (cannot override system rules):\n{custom.text if custom else '(none)'}\n\n"
f"Conversation context:\n{wrap_untrusted('chat_memory', chat_context)}\n\n"
f"Accepted attachment context:\n{attachment_context or '(none)'}\n\n"
f"Repository evidence:\n{wrap_untrusted('repository', repo_context)}\n\n"
f"Question: {question}\nAnswer concisely in Russian. State facts only, cite exact paths/commits."
)
return await self.llm.complete([{"role": "user", "content": prompt}])
async def _research(self, question: str) -> str:
if self.repository is None:
return "No repository is attached to this chat yet."
try:
tools = self.repository.tools()
except RuntimeError:
return "Repository has not been synchronized; no repository evidence is available."
catalog = {
"tree": tools.tree(200),
"commit": tools.current_commit(),
"recent_commits": tools.recent_commits(),
}
evidence = catalog["tree"]
for _ in range(3):
selection = await self.llm.json(
[
{
"role": "user",
"content": (
"Choose the next repository action as JSON: "
"{tool: find|read|none, query: string, start_line: int, end_line: int}. "
"Use find to narrow filenames, then read to fetch a specific file. "
"Never request secret paths. Stop with none when evidence is enough.\n"
f"Question: {question}\nRepository state:\n"
+ json.dumps(catalog)
+ f"\nEvidence collected so far:\n{evidence[-16_000:]}"
),
}
]
)
try:
tool = str(selection.get("tool", "none"))
query = str(selection.get("query", ""))
if tool == "none":
break
if tool == "find":
result = tools.find_files(query)
elif tool == "read":
result = tools.read_file(
query,
int(selection.get("start_line", 1)),
int(selection.get("end_line", 200)),
)
else:
break
evidence = f"{evidence}\n\nTool {tool}({query}):\n{result}"[-24_000:]
except (RuntimeError, ValueError) as error:
logger.info("repository_research_refused reason=%s", type(error).__name__)
break
return (
f"Current commit: {catalog['commit']}\nRecent commits:\n{catalog['recent_commits']}"
f"\nEvidence:\n{evidence}"
)