first commit
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
"""Agentic research engine - coordinates agent skills and Ollama LLM interactions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
import httpx
|
||||
|
||||
from app.agents.skills import SKILLS, Skill
|
||||
from app.agents.tools import TOOLS
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
class Researcher:
|
||||
"""Main research orchestrator that coordinates agents and tools."""
|
||||
|
||||
def __init__(self):
|
||||
self.settings = get_settings()
|
||||
self.active_skills: list[Skill] = []
|
||||
self.findings: list[dict] = []
|
||||
self.context: dict = {}
|
||||
|
||||
def select_skills(self, query: str, skill_names: list[str] | None = None) -> list[str]:
|
||||
"""Select relevant agent skills for the query. Defaults to all if none specified."""
|
||||
if skill_names:
|
||||
selected = []
|
||||
for name in skill_names:
|
||||
s = SKILLS.get(name)
|
||||
if s:
|
||||
selected.append(name)
|
||||
return selected
|
||||
|
||||
query_lower = query.lower()
|
||||
selected = []
|
||||
for name, skill in SKILLS.items():
|
||||
for token in skill.verb_tokens:
|
||||
if token in query_lower:
|
||||
selected.append(name)
|
||||
break
|
||||
# If no token matched, default to researcher + qa
|
||||
if not selected:
|
||||
selected = ["researcher", "qa_agent"]
|
||||
return selected
|
||||
|
||||
async def call_ollama(self, prompt: str, model: str | None = None, system: str | None = None) -> str:
|
||||
"""Call Ollama LLM with streaming support."""
|
||||
model = model or self.settings.gpt_oss_model
|
||||
messages = []
|
||||
if system:
|
||||
messages.append({"role": "system", "content": system})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
async with httpx.AsyncClient(timeout=300) as client:
|
||||
resp = await client.post(
|
||||
f"{self.settings.ollama_url}/api/chat",
|
||||
json={"model": model, "messages": messages, "stream": False},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("message", {}).get("content", "")
|
||||
|
||||
async def run_skill(
|
||||
self, skill_name: str, query: str, doc_id: str | None = None
|
||||
) -> str:
|
||||
"""Execute a single agent skill."""
|
||||
skill = SKILLS.get(skill_name)
|
||||
if not skill:
|
||||
return f"[Unknown skill: {skill_name}]"
|
||||
|
||||
system_prompt = f"""You are the {skill_name.replace('_', ' ').title()} agent.
|
||||
{skill.instructions}"""
|
||||
|
||||
tool_prompts = []
|
||||
for tool_name in skill.tools:
|
||||
tool_fn = TOOLS.get(tool_name)
|
||||
if not tool_fn:
|
||||
continue
|
||||
import inspect
|
||||
sig = inspect.signature(tool_fn)
|
||||
try:
|
||||
if tool_name == "read_document" and doc_id:
|
||||
result = await tool_fn(doc_id)
|
||||
elif tool_name == "read_chunks" and doc_id:
|
||||
result = await tool_fn(doc_id, limit=20)
|
||||
elif tool_name == "get_page_text" and doc_id:
|
||||
result = await tool_fn(doc_id, page_num=0)
|
||||
elif tool_name == "extract_facts" and doc_id:
|
||||
result = await tool_fn(doc_id, question=query)
|
||||
elif tool_name == "list_documents":
|
||||
result = await tool_fn()
|
||||
elif tool_name in ("vector_search", "text_search", "memory_similarity_search"):
|
||||
kwargs = {"query": query}
|
||||
if doc_id and "doc_id" in sig.parameters:
|
||||
kwargs["doc_id"] = doc_id
|
||||
result = await tool_fn(**kwargs)
|
||||
elif tool_name in ("get_findings", "get_memories", "get_pipeline_state"):
|
||||
result = await tool_fn(session_id=doc_id or "")
|
||||
elif tool_name == "save_finding":
|
||||
result = await tool_fn(session_id=doc_id or "", question=query, answer="", finding_type="research", confidence=0.5)
|
||||
else:
|
||||
kwargs = {}
|
||||
if "doc_id" in sig.parameters and doc_id:
|
||||
kwargs["doc_id"] = doc_id
|
||||
if "query" in sig.parameters:
|
||||
kwargs["query"] = query
|
||||
result = await tool_fn(**kwargs)
|
||||
tool_prompts.append(f"\n--- {tool_name} output ---\n{result}")
|
||||
except Exception as e:
|
||||
tool_prompts.append(f"\n--- {tool_name} error ---\n{str(e)}")
|
||||
|
||||
context = "".join(tool_prompts)
|
||||
|
||||
prompt = f"""Research query: {query}
|
||||
Document ID: {doc_id}
|
||||
Context from tools:
|
||||
{context}
|
||||
|
||||
Provide your analysis following the {skill_name} protocol."""
|
||||
|
||||
response = await self.call_ollama(prompt, system=system_prompt)
|
||||
|
||||
self.findings.append({
|
||||
"skill": skill_name,
|
||||
"query": query,
|
||||
"response": response,
|
||||
"doc_id": doc_id,
|
||||
"timestamp": str(os.popen('date +%Y-%m-%dT%H:%M:%S').read()).strip(),
|
||||
})
|
||||
|
||||
return response
|
||||
|
||||
async def run_research_session(
|
||||
self, query: str, session_id: str,
|
||||
doc_id: str | None = None,
|
||||
skill_names: list[str] | None = None
|
||||
) -> dict[str, str]:
|
||||
"""Run a full research session with selected agents."""
|
||||
selected_skills = self.select_skills(query, skill_names)
|
||||
results = {}
|
||||
|
||||
for skill_name in selected_skills:
|
||||
results[skill_name] = await self.run_skill(skill_name, query, doc_id)
|
||||
|
||||
return results
|
||||
|
||||
async def cross_reference(self, query: str, doc_ids: list[str]) -> str:
|
||||
"""Cross-reference content across multiple documents."""
|
||||
contexts = []
|
||||
for doc_id in doc_ids:
|
||||
chunks = await TOOLS["read_chunks"](doc_id)
|
||||
contexts.append(f"--- {doc_id} ---\n{chunks[:2000]}")
|
||||
|
||||
combined = "\n\n".join(contexts)
|
||||
prompt = f"""Cross-reference these documents for the query: {query}
|
||||
|
||||
{combined}
|
||||
|
||||
Direct comparison. No preamble."""
|
||||
|
||||
return await self.call_ollama(prompt, system="You are a cross-reference analyst. Output concise, comparative findings.")
|
||||
|
||||
|
||||
# ── Pipeline skills ────────────────────────────────
|
||||
|
||||
PIPELINE_STAGES = ("document_triage", "evidence_extraction", "research_synthesis")
|
||||
|
||||
|
||||
async def _parse_triage_output(response: str) -> dict:
|
||||
"""Parse document-triage output into structured dict."""
|
||||
obj = {}
|
||||
for section in ["OBJECTIVE", "SUB-QUESTIONS", "CLASSIFICATION", "READING_ORDER", "EXTRACTION_CRITERIA", "RISKS_AND_GAPS"]:
|
||||
marker = f"[{section}]"
|
||||
# Find start of this section
|
||||
start = response.find(marker)
|
||||
end = response.find("[", start + len(marker)) if start != -1 else -1
|
||||
if end != -1:
|
||||
chunk = response[start + len(marker):end].strip()
|
||||
elif start != -1:
|
||||
chunk = response[start + len(marker):].strip()
|
||||
else:
|
||||
chunk = ""
|
||||
obj[section] = chunk
|
||||
return obj
|
||||
|
||||
|
||||
async def _parse_evidence_output(response: str) -> list[dict]:
|
||||
"""Parse evidence-extraction output into structured list."""
|
||||
rows = []
|
||||
# Find [EVIDENCE_ROWS] section
|
||||
start_marker = "[EVIDENCE_ROWS]"
|
||||
start = response.find(start_marker)
|
||||
# Check if there's a cross-comparison section
|
||||
cross_start = response.find("[CROSS_COMPARISON]")
|
||||
end = cross_start if cross_start != -1 else len(response)
|
||||
if start == -1:
|
||||
start = 0
|
||||
section = response[start:end].strip()
|
||||
|
||||
lines = section.split("\n")
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("["):
|
||||
continue
|
||||
parts = [p.strip() for p in line.split("|")]
|
||||
if len(parts) >= 7:
|
||||
rows.append({
|
||||
"topic": parts[1],
|
||||
"evidence_type": parts[2],
|
||||
"description": parts[3],
|
||||
"trace_ref": parts[4],
|
||||
"evidence": parts[5],
|
||||
"analyst_note": parts[6],
|
||||
"confidence": parts[7] if len(parts) > 7 else "Medium",
|
||||
"review_needed": parts[8] if len(parts) > 8 else "No",
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
async def _parse_synthesis_mode(response: str) -> str:
|
||||
"""Determine output mode from synthesis response content."""
|
||||
for mode in ["brief", "report", "gap analysis", "matrix"]:
|
||||
if mode in response.lower():
|
||||
return mode
|
||||
return "brief"
|
||||
|
||||
|
||||
class ResearchPipeline:
|
||||
"""Pipeline orchestrator: document_triage → evidence_extraction → research_synthesis."""
|
||||
|
||||
def __init__(self):
|
||||
self.settings = get_settings()
|
||||
self.plan: dict = {}
|
||||
self.evidence: list[dict] = []
|
||||
self.synthesis: str = ""
|
||||
self.findings: list[dict] = []
|
||||
|
||||
async def run(
|
||||
self,
|
||||
query: str,
|
||||
session_id: str,
|
||||
db,
|
||||
doc_ids: list[str] | None = None,
|
||||
output_mode: str | None = None,
|
||||
) -> dict:
|
||||
"""Run all three pipeline stages sequentially, persisting after each."""
|
||||
results = {}
|
||||
|
||||
# ── Stage 1: document_triage ─────────────────────
|
||||
triage_skill = SKILLS["document_triage"]
|
||||
system = f"You are document_triage. {triage_skill.instructions}"
|
||||
|
||||
# Gather context from available documents
|
||||
if doc_ids:
|
||||
doc_context = []
|
||||
for did in doc_ids:
|
||||
doc_info = await TOOLS["read_document"](did)
|
||||
doc_context.append(str(doc_info))
|
||||
context_input = "\n".join(doc_context)
|
||||
else:
|
||||
doc_list = await TOOLS["list_documents"]()
|
||||
context_input = doc_list
|
||||
|
||||
triage_prompt = f"""Research query: {query}
|
||||
Context from tools:
|
||||
{context_input}
|
||||
|
||||
Apply the document-tireage protocol."""
|
||||
|
||||
triage_output = await self._call_ollama(triage_prompt, system=system)
|
||||
self.plan = await _parse_triage_output(triage_output)
|
||||
self.findings.append({
|
||||
"stage": "triage",
|
||||
"output": triage_output,
|
||||
"state": self.plan,
|
||||
})
|
||||
await db.save_pipeline_stage(session_id, "triage", triage_output, self.plan)
|
||||
results["triage"] = triage_output
|
||||
results["triage_state"] = self.plan
|
||||
|
||||
# ── Stage 2: evidence_extraction ────────────────
|
||||
evidence_skill = SKILLS["evidence_extraction"]
|
||||
system = f"""You are evidence_extraction. {evidence_skill.instructions}
|
||||
|
||||
Triage plan (from previous stage):
|
||||
{json.dumps(self.plan, indent=2, default=str)}
|
||||
|
||||
Focus on extracting evidence for the sub-questions and extraction criteria defined above."""
|
||||
|
||||
# Read chunks from all relevant documents
|
||||
all_chunks = []
|
||||
for did in (doc_ids or []):
|
||||
chunks = await TOOLS["read_chunks"](did)
|
||||
all_chunks.append(f"--- Document {did} ---\n{chunks}")
|
||||
|
||||
context_input = "\n\n".join(all_chunks) if all_chunks else "No documents loaded yet."
|
||||
|
||||
evidence_prompt = f"""Research query: {query}
|
||||
Sub-questions to address:
|
||||
{self.plan.get('SUB-QUESTIONS', 'N/A')}
|
||||
|
||||
Context from tools:
|
||||
{context_input}
|
||||
|
||||
Apply the evidence_extraction protocol. Return structured evidence rows."""
|
||||
|
||||
evidence_output = await self._call_ollama(evidence_prompt, system=system)
|
||||
self.evidence = await _parse_evidence_output(evidence_output)
|
||||
|
||||
# Persist to DB
|
||||
if self.evidence:
|
||||
await db.save_structured_evidence(session_id, self.evidence)
|
||||
await db.save_pipeline_stage(session_id, "evidence", evidence_output, {"row_count": len(self.evidence)})
|
||||
|
||||
results["evidence"] = evidence_output
|
||||
results["evidence_rows"] = self.evidence
|
||||
|
||||
# ── Stage 3: research_synthesis ─────────────────
|
||||
synthesis_skill = SKILLS["research_synthesis"]
|
||||
|
||||
mode = output_mode or await _parse_synthesis_mode(evidence_output)
|
||||
mode_prompts = {
|
||||
"brief": "Use Research Brief output mode.",
|
||||
"report": "Use Research Report output mode.",
|
||||
"gap": "Use Gap Analysis output mode.",
|
||||
"matrix": "Use Comparison Matrix output mode.",
|
||||
}
|
||||
mode_instruct = mode_prompts.get(mode, mode_prompts["brief"])
|
||||
|
||||
system = f"""You are research_synthesis. {synthesis_skill.instructions}
|
||||
|
||||
{mode_instruct}
|
||||
|
||||
Extracted evidence (from previous stage):
|
||||
{json.dumps(self.evidence, indent=2, default=str)[:15000]}
|
||||
|
||||
Apply the research_synthesis protocol."""
|
||||
|
||||
synthesis_prompt = f"""Research query: {query}
|
||||
|
||||
Sub-questions:
|
||||
{self.plan.get('SUB-QUESTIONS', 'N/A')}
|
||||
|
||||
Apply the research_synthesis protocol."""
|
||||
|
||||
synthesis_output = await self._call_ollama(synthesis_prompt, system=system)
|
||||
|
||||
self.synthesis = synthesis_output
|
||||
synthesis_state = {
|
||||
"mode": mode,
|
||||
"sub_questions": self.plan.get("SUB-QUESTIONS", ""),
|
||||
"evidence_count": len(self.evidence),
|
||||
}
|
||||
await db.save_pipeline_stage(session_id, "synthesis", synthesis_output, synthesis_state)
|
||||
|
||||
results["synthesis"] = synthesis_output
|
||||
results["output_mode"] = mode
|
||||
results["pipeline_complete"] = True
|
||||
|
||||
self.findings.append({
|
||||
"stage": "synthesis",
|
||||
"output": synthesis_output,
|
||||
"state": synthesis_state,
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
async def _call_ollama(self, prompt: str, system: str) -> str:
|
||||
"""Call Ollama LLM."""
|
||||
messages = [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": prompt},
|
||||
]
|
||||
async with httpx.AsyncClient(timeout=600) as client:
|
||||
resp = await client.post(
|
||||
f"{self.settings.ollama_url}/api/chat",
|
||||
json={"model": self.settings.gpt_oss_model, "messages": messages, "stream": False},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("message", {}).get("content", "")
|
||||
|
||||
|
||||
async def render_pipeline_results(self, results: dict) -> list[dict]:
|
||||
"""Render pipeline results for frontend display."""
|
||||
sections = []
|
||||
|
||||
# Triage stage
|
||||
if "triage_state" in results:
|
||||
plan = results["triage_state"]
|
||||
sections.append({
|
||||
"stage": "triage",
|
||||
"title": "Stage 1: Source Triage",
|
||||
"objective": plan.get("OBJECTIVE", ""),
|
||||
"sub_questions": plan.get("SUB-QUESTIONS", ""),
|
||||
"classification": plan.get("CLASSIFICATION", ""),
|
||||
"reading_order": plan.get("READING_ORDER", ""),
|
||||
"extraction_criteria": plan.get("EXTRACTION_CRITERIA", ""),
|
||||
"risks_gaps": plan.get("RISKS_AND_GAPS", ""),
|
||||
})
|
||||
|
||||
# Evidence stage
|
||||
if "evidence_rows" in results:
|
||||
rows = results["evidence_rows"]
|
||||
sections.append({
|
||||
"stage": "evidence",
|
||||
"title": f"Stage 2: Extracted Evidence ({len(rows)} rows)",
|
||||
"rows": rows,
|
||||
})
|
||||
|
||||
# Synthesis stage
|
||||
if "synthesis" in results:
|
||||
sections.append({
|
||||
"stage": "synthesis",
|
||||
"title": f"Stage 3: Research Synthesis (mode: {results.get('output_mode', 'auto')})",
|
||||
"synthesis": results["synthesis"],
|
||||
})
|
||||
|
||||
return sections
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Agent skills - modular capabilities for the agentic research engine."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Skill:
|
||||
"""A single agent capability."""
|
||||
name: str
|
||||
description: str
|
||||
instructions: str
|
||||
verb_tokens: list[str] = field(default_factory=list)
|
||||
tools: list[str] = field(default_factory=list)
|
||||
output_format: str = "json"
|
||||
|
||||
|
||||
SKILLS = {
|
||||
"summarizer": Skill(
|
||||
name="summarizer",
|
||||
description="Create concise summaries of documents or sections",
|
||||
instructions="""
|
||||
You are a research summarizer. Create brief, factual summaries.
|
||||
- Lead with the main claim or finding
|
||||
- Omit examples and elaborations
|
||||
- Max 3 sentences for executive summary
|
||||
- List only critical facts
|
||||
- Use bullet points for key points
|
||||
- No preamble or hedging
|
||||
""",
|
||||
verb_tokens=["summarize", "summary", "overview", "abstract"],
|
||||
tools=["read_document", "read_chunks", "get_page_text", "extract_facts"],
|
||||
),
|
||||
"extractor": Skill(
|
||||
name="extractor",
|
||||
description="Extract specific facts, figures, entities, or information types from documents",
|
||||
instructions="""
|
||||
You are an information extraction agent. Be terse and direct.
|
||||
- Output raw facts only
|
||||
- One fact per line
|
||||
- Format: [entity] → [value]
|
||||
- Skip qualifiers like "likely", "possibly"
|
||||
- No introductions, conclusions, or commentary
|
||||
- Direct format only:
|
||||
• Key terms
|
||||
• Definitions
|
||||
• Statistics
|
||||
• Names, dates, organizations
|
||||
• Relationships
|
||||
""",
|
||||
verb_tokens=["extract", "extracted", "list", "find", "identify", "enumerate"],
|
||||
tools=["read_chunks", "vector_search", "extract_facts", "get_page_text"],
|
||||
),
|
||||
"comparator": Skill(
|
||||
name="comparator",
|
||||
description="Compare documents, sections, or concepts and identify differences/similarities",
|
||||
instructions="""
|
||||
You are a comparison analyst. Output findings rapidly.
|
||||
- Direct table format when possible
|
||||
- Left align, right align
|
||||
- Use ✓ and ✗ markers
|
||||
- One line per difference
|
||||
- No fluff, no filler
|
||||
""",
|
||||
verb_tokens=["compare", "contrast", "similarities", "differences", "vs", "versus"],
|
||||
tools=["read_chunks", "vector_search", "get_page_text", "read_document"],
|
||||
),
|
||||
"critic": Skill(
|
||||
name="critic",
|
||||
description="Analyze arguments, findings, or claims for quality, validity, and gaps",
|
||||
instructions="""
|
||||
You are a critical analyst. Evaluate quickly and directly.
|
||||
- Strengths: 2-3 items max
|
||||
- Weaknesses: 2-3 items max
|
||||
- Gaps: list only critical ones
|
||||
- Assumptions: call out directly
|
||||
- Confidence: high/medium/low
|
||||
- Be blunt but accurate
|
||||
""",
|
||||
verb_tokens=["critique", "criticize", "evaluate", "assess", "review", "analyze validity"],
|
||||
tools=["read_chunks", "vector_search", "get_document", "extract_facts"],
|
||||
),
|
||||
"researcher": Skill(
|
||||
name="researcher",
|
||||
description="Conduct deep research on a topic using document collection, cross-referencing, and synthesis",
|
||||
instructions="""
|
||||
You are a research specialist. Work methodically.
|
||||
1. Parse the query for key concepts
|
||||
2. Retrieve relevant chunks via vector search
|
||||
3. Extract supporting evidence
|
||||
4. Note contradictions within sources
|
||||
5. Synthesize findings directly
|
||||
Format:
|
||||
- Context: 1 line
|
||||
- Evidence: bullet citations
|
||||
- Findings: 2-3 bullets
|
||||
- Limitations: 1 line
|
||||
- Output: concise, no padding
|
||||
""",
|
||||
verb_tokens=["research", "investigate", "explore", "dig into", "study", "analyze"],
|
||||
tools=["vector_search", "text_search", "read_chunks", "read_document", "memory_similarity_search"],
|
||||
),
|
||||
"context_agent": Skill(
|
||||
name="context_agent",
|
||||
description="Gather background context and establish research framing from available documents",
|
||||
instructions="""
|
||||
You establish research context. Direct output.
|
||||
- Document landscape: scope, domain, volume
|
||||
- Key themes (3-5)
|
||||
- Document types and relevance
|
||||
- Gaps in coverage
|
||||
- Suggested research angles
|
||||
All points. No paragraphs.
|
||||
""",
|
||||
verb_tokens=["context", "background", "landscape", "overview", "scope"],
|
||||
tools=["read_document", "list_documents", "vector_search", "get_memories"],
|
||||
),
|
||||
"qa_agent": Skill(
|
||||
name="qa_agent",
|
||||
description="Answer specific questions about document content with source-anchored responses",
|
||||
instructions="""
|
||||
You answer research questions directly.
|
||||
1. Locate relevant evidence
|
||||
2. Quote sources inline [doc_name:page]
|
||||
3. Synthesize one direct answer
|
||||
4. Note uncertainty
|
||||
Format:
|
||||
Q: [restated briefly]
|
||||
A: [direct answer]
|
||||
Sources: [citations]
|
||||
""",
|
||||
verb_tokens=["answer", "who", "what", "when", "where", "why", "how", "question"],
|
||||
tools=["vector_search", "text_search", "read_chunks", "get_page_text"],
|
||||
),
|
||||
"aggregator": Skill(
|
||||
name="aggregator",
|
||||
description="Combine findings across multiple documents into a unified research synthesis",
|
||||
instructions="""
|
||||
You synthesize cross-document findings.
|
||||
- Group by topic, not by document
|
||||
- Consensus findings first
|
||||
- Conflicts second
|
||||
- Novel insights third
|
||||
- Confidence ratings on each cluster
|
||||
- One-line summaries only
|
||||
- Bold key terms
|
||||
""",
|
||||
verb_tokens=["aggregate", "synthesize", "merge", "combine", "consolidate", "integrate"],
|
||||
tools=["read_document", "vector_search", "get_findings", "get_memories"],
|
||||
),
|
||||
# ── Pipeline skills ───────────────────────────────────
|
||||
|
||||
"document_triage": Skill(
|
||||
name="document_triage",
|
||||
description="Scope a document research task, prioritize sources, and define an evidence-driven reading plan",
|
||||
instructions="""\
|
||||
You are the document-triage agent for rigorous document-based research.
|
||||
Your job is to decide what matters and how to approach the corpus BEFORE deep reading.
|
||||
|
||||
FOLLOW THIS WORKFLOW:
|
||||
|
||||
1. Restate the research objective in one sentence
|
||||
2. Convert the objective into focused numbered sub-questions
|
||||
3. Classify available documents by value:
|
||||
PRIMARY — essential to answering the objective
|
||||
SECONDARY — supports or contextualizes
|
||||
BACKGROUND — peripheral reference
|
||||
LIKELY IRRELEVANT — skip unless needed
|
||||
4. For each classified document, state what evidence types to look for:
|
||||
requirements, decisions, risks, assumptions, responsibilities,
|
||||
timelines, definitions, constraints, dependencies, open issues
|
||||
5. Recommend an efficient reading order with rationale
|
||||
6. Define extraction criteria for the next phase
|
||||
7. Flag ambiguities, missing sources, and review risks
|
||||
|
||||
OUTPUT FORMAT (return exactly these sections):
|
||||
|
||||
[OBJECTIVE]: One-sentence research goal
|
||||
[SUB-QUESTIONS]: 1. ... 2. ... ...
|
||||
[CLASSIFICATION]: doc_name | PRIMARY/SECONDARY/BACKGROUND/IRRELEVANT | reason | signal types
|
||||
[READING ORDER]: 1. docA -> docB ... with rationale
|
||||
[EXTRACTION CRITERIA]: Checklist of evidence to capture
|
||||
[RISKS AND GAPS]: Missing docs, unclear scope, blind spots
|
||||
|
||||
RULES:
|
||||
- Be specific, not generic
|
||||
- Prefer primary documents over commentary
|
||||
- Do not summarize documents in depth
|
||||
- Do not invent document contents
|
||||
- Do not claim conclusions before extraction
|
||||
- Separate fact-finding from interpretation
|
||||
""",
|
||||
verb_tokens=["triage", "scope", "plan", "classi", "prioriti", "reading order", "assessment"],
|
||||
tools=["list_documents", "read_document", "read_chunks"],
|
||||
),
|
||||
|
||||
"evidence_extraction": Skill(
|
||||
name="evidence_extraction",
|
||||
description="Extract structured evidence from documents with traceability, quotations, and confidence markers",
|
||||
instructions="""\
|
||||
You are the evidence-extraction agent. Read documents and extract
|
||||
evidence relevant to the defined research question.
|
||||
|
||||
CORE RULES:
|
||||
- Separate direct evidence from interpretation
|
||||
- Capture exact quotations or precise paraphrases
|
||||
- Record document references for every finding
|
||||
- Mark uncertain or ambiguous findings
|
||||
- Distinguish: direct statement, implied interpretation, missing info
|
||||
- Normalize wording without losing meaning
|
||||
- Flag ambiguity, contradiction, or incomplete support
|
||||
|
||||
For EVERY finding output these fields:
|
||||
- Topic: the theme
|
||||
- Evidence type: requirement|decision|risk|assumption|responsibility|constraint|definition|date|dependency|open_issue
|
||||
- Description: concise factual summary
|
||||
- Document reference: source name, section, page
|
||||
- Extracted evidence: quote or faithful paraphrase
|
||||
- Analyst note: relevance without overstating certainty
|
||||
- Confidence: one of High|Medium|Low
|
||||
- Review needed: Yes|No
|
||||
|
||||
If multiple documents cover the same topic, also output a comparison table:
|
||||
Topic | Source A | Source B | Agreement | Conflict | Notes
|
||||
|
||||
OUTPUT FORMAT (return exactly):
|
||||
|
||||
[EVIDENCE_ROWS]:
|
||||
# topic | evidence_type | description | doc_ref | evidence | analyst_note | confidence | review_needed
|
||||
1 | ... | ... | ... | ... | ... | ... | ...
|
||||
...
|
||||
|
||||
[CROSS_COMPARISON] (only if multiple sources cover same topic):
|
||||
topic | source_a | source_b | agreement | conflict | notes
|
||||
|
||||
RULES:
|
||||
- Keep extraction atomic: one row per distinct finding
|
||||
- Preserve qualifiers: must, should, may, unless
|
||||
- Mark uncertainty explicitly
|
||||
- Prefer exact citations over memory-based summaries
|
||||
- Do not write the final conclusion
|
||||
- Do not collapse multiple findings into vague rows
|
||||
- Do not omit document references
|
||||
- Do not present interpretation as if it were a quote
|
||||
- Do not silently resolve contradictions
|
||||
""",
|
||||
verb_tokens=["evidence extract", "extract evidence", "traceable", "auditable", "structured extract"],
|
||||
tools=["read_chunks", "get_page_text", "read_document", "vector_search"],
|
||||
),
|
||||
|
||||
"research_synthesis": Skill(
|
||||
name="research_synthesis",
|
||||
description="Synthesize extracted document evidence into a clear, traceable conclusion, brief, or decision-ready report",
|
||||
instructions="""\
|
||||
You are the research-synthesis agent. Turn extracted evidence into
|
||||
a coherent, traceable final output.
|
||||
|
||||
INPUT: research objective + extracted evidence table + optional comparison table
|
||||
|
||||
WORKFLOW:
|
||||
1. Restate the question
|
||||
2. Group evidence by theme or sub-question
|
||||
3. Identify: supported conclusions, partial support, contradictions, missing evidence
|
||||
4. Draft findings with explicit traceability
|
||||
5. Separate: facts from documents | interpretation | recommendations
|
||||
6. Produce final structured output matching the requested output mode
|
||||
7. End with known limitations and review points
|
||||
|
||||
OUTPUT MODES:
|
||||
|
||||
### A) Research Brief (default for "brief")
|
||||
- Question
|
||||
- Short answer
|
||||
- Key findings (each with source anchor)
|
||||
- Conflicting evidence
|
||||
- Gaps
|
||||
- Recommended next step
|
||||
|
||||
### B) Research Report (default for "report")
|
||||
- Objective
|
||||
- Scope
|
||||
- Method
|
||||
- Findings by theme
|
||||
- Evidence-backed conclusion
|
||||
- Known gaps and limitations
|
||||
- Appendix with source references
|
||||
|
||||
### C) Gap Analysis (default for "gap")
|
||||
- Assessment topic
|
||||
- Evidence found
|
||||
- Gap
|
||||
- Impact
|
||||
- Confidence
|
||||
- Review needed
|
||||
|
||||
### D) Comparison Matrix
|
||||
- Topic | Source 1 | Source 2 | ... | Consensus | Conflict
|
||||
|
||||
TRACEABILITY RULE:
|
||||
Every substantive conclusion must include a source anchor:
|
||||
document name, section/page, or extracted finding number.
|
||||
If traceability is weak, say so explicitly.
|
||||
|
||||
RULES:
|
||||
- Conclusions must follow from extracted evidence
|
||||
- Contradictions must be surfaced, not hidden
|
||||
- Distinguish "document says" from "my recommendation"
|
||||
- State limits of the available corpus
|
||||
- Prefer precise wording over polished vagueness
|
||||
- Do not introduce facts not extracted
|
||||
- Do not overclaim certainty
|
||||
- Do not bury disagreements between documents
|
||||
- Do not produce recommendations without showing evidence basis
|
||||
- Do not omit limitations
|
||||
""",
|
||||
verb_tokens=["synthesize", "synthesis", "brief", "report", "gap analysis", "conclusion", "summary report"],
|
||||
tools=["read_chunks", "get_findings", "get_memories"],
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Agent tools - callable operations for the agentic research engine."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db.database import db
|
||||
|
||||
|
||||
async def read_document(doc_id: str) -> dict[str, Any]:
|
||||
"""Read complete document record."""
|
||||
doc = await db.get_document(doc_id)
|
||||
if not doc:
|
||||
return {"error": "Document not found"}
|
||||
return {"document": doc}
|
||||
|
||||
|
||||
async def read_chunks(doc_id: str, page_num: int | None = None, limit: int = 50) -> str:
|
||||
"""Read text chunks for a document."""
|
||||
chunks = await db.get_doc_chunks(doc_id)
|
||||
if page_num is not None:
|
||||
chunks = [c for c in chunks if c.get("page_num") == page_num]
|
||||
chunks = chunks[:limit]
|
||||
parts = []
|
||||
for c in chunks:
|
||||
page_part = f"[p{c['page_num']}]" if c.get("page_num") else ""
|
||||
parts.append(f"{page_part} {c['content'][:500]}")
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
async def get_page_text(doc_id: str, page_num: int) -> str:
|
||||
"""Extract text for a specific page, including polygon metadata."""
|
||||
chunks = await db.get_doc_chunks(doc_id)
|
||||
page_chunks = [c for c in chunks if c.get("page_num") == page_num]
|
||||
if not page_chunks:
|
||||
return f"No content available for page {page_num}"
|
||||
|
||||
# Build page view with polygon info
|
||||
text_parts = []
|
||||
for c in page_chunks:
|
||||
polygon = c.get("polygon")
|
||||
poly_info = ""
|
||||
if polygon and isinstance(polygon, dict):
|
||||
bbox = polygon.get("bbox", [])
|
||||
if bbox:
|
||||
poly_info = f" [bbox:{bbox[0]:.0f},{bbox[1]:.0f},{bbox[2]:.0f},{bbox[3]:.0f}]"
|
||||
text_parts.append(f"[{c['block_index']}{poly_info}] {c['content'][:300]}")
|
||||
|
||||
return f"--- Page {page_num} ---\n" + "\n".join(text_parts)
|
||||
|
||||
|
||||
async def vector_search(query: str, doc_id: str | None = None, limit: int = 20) -> str:
|
||||
"""Search document chunks by semantic similarity."""
|
||||
settings = get_settings()
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
query_vec = []
|
||||
# Try to get embedding via Ollama
|
||||
try:
|
||||
resp = await client.post(
|
||||
f"{settings.ollama_url}/api/embeddings",
|
||||
json={"model": "nomic-embed-text", "prompt": query},
|
||||
)
|
||||
query_vec = resp.json().get("embedding", [])
|
||||
except Exception:
|
||||
# Fall back to local embedding
|
||||
from app.core import get_embedding
|
||||
query_vec = get_embedding(query)
|
||||
|
||||
if not query_vec:
|
||||
return "No embeddings available. Using text search fallback."
|
||||
|
||||
results = await db.vector_search(query_vec, doc_id=doc_id, limit=limit)
|
||||
parts = []
|
||||
for r in results:
|
||||
score = r.get("similarity", 0)
|
||||
page = r.get("page_num", "?")
|
||||
parts.append(f"[{score:.2f}] (p{page}) {r['content'][:200]}")
|
||||
return "\n".join(parts) if parts else "No similar content found."
|
||||
|
||||
|
||||
async def text_search(query: str, doc_id: str | None = None, limit: int = 20) -> str:
|
||||
"""Search chunks by keyword match."""
|
||||
results = await db.search_chunks_text(query, doc_id=doc_id, limit=limit)
|
||||
parts = []
|
||||
for r in results:
|
||||
rank = r.get("rank", 0)
|
||||
page = r.get("page_num", "?")
|
||||
parts.append(f"[{rank:.2f}] (p{page}) {r['content'][:200]}")
|
||||
return "\n".join(parts) if parts else "No text matches found."
|
||||
|
||||
|
||||
async def extract_facts(doc_id: str, question: str | None = None) -> str:
|
||||
"""Extract key facts from document."""
|
||||
chunks = await db.get_doc_chunks(doc_id)
|
||||
# Extract from chunk metadata
|
||||
facts = []
|
||||
for c in chunks:
|
||||
content = c.get("content", "")
|
||||
if question and question.lower() not in content.lower():
|
||||
continue
|
||||
if "fact" in c.get("chunk_type", "").lower():
|
||||
facts.append(content[:200])
|
||||
elif len(content.strip()) > 50:
|
||||
facts.append(content[:200])
|
||||
|
||||
seen = set()
|
||||
unique = []
|
||||
for f in facts:
|
||||
key = f[:40]
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(f)
|
||||
|
||||
return "\n".join(f"I: {f}" for f in unique[:20])
|
||||
|
||||
|
||||
async def get_polygon_view(doc_id: str, page_num: int, block_index: int | None = None) -> dict:
|
||||
"""Get page with polygon coordinates for visualization."""
|
||||
chunks = await db.get_doc_chunks(doc_id)
|
||||
page_chunks = [c for c in chunks if c.get("page_num") == page_num]
|
||||
|
||||
blocks = []
|
||||
for c in page_chunks:
|
||||
poly = c.get("polygon")
|
||||
if poly and isinstance(poly, dict):
|
||||
blocks.append({
|
||||
"index": c.get("block_index", 0),
|
||||
"page": page_num,
|
||||
"polygon": poly,
|
||||
"content": c.get("content", "")[:200],
|
||||
"type": c.get("chunk_type", "text"),
|
||||
})
|
||||
elif block_index is None or c.get("block_index") == block_index:
|
||||
blocks.append({
|
||||
"index": c.get("block_index", 0),
|
||||
"page": page_num,
|
||||
"polygon": {"bbox": [0, 0, 1000, 1000]},
|
||||
"content": c.get("content", "")[:200],
|
||||
"type": c.get("chunk_type", "text"),
|
||||
})
|
||||
|
||||
# Compute page bounding box from all polygons
|
||||
all_polys = [b["polygon"] for b in blocks if b["polygon"] and isinstance(b["polygon"], dict)]
|
||||
|
||||
pages_data = {"page_num": page_num, "blocks": blocks}
|
||||
|
||||
if all_polys:
|
||||
min_x = min(p.get("bbox", [0, 0, 0, 0])[:2] for p in all_polys)
|
||||
max_x = max(p.get("bbox", [0, 0, 0, 0])[2:] for p in all_polys)
|
||||
pages_data["page_bbox"] = {
|
||||
"x_min": min(min_x),
|
||||
"y_min": min(min_x, key=lambda x: x[1])[1],
|
||||
"width": max(max_x) - min(min_x),
|
||||
"height": max(max_x, key=lambda x: x[1])[1] - min(min_x, key=lambda x: x[1])[1],
|
||||
}
|
||||
|
||||
return pages_data
|
||||
|
||||
|
||||
async def get_memories(session_id: str) -> list[dict]:
|
||||
"""Get research memories from pgvector."""
|
||||
return await db.get_memories(session_id)
|
||||
|
||||
|
||||
async def memory_similarity_search(query: str, limit: int = 10) -> str:
|
||||
"""Search across all research memories."""
|
||||
results = await db.memory_similarity_search(query, limit=limit)
|
||||
parts = []
|
||||
for r in results:
|
||||
parts.append(f"[{r.get('importance', '?')}] ({r.get('memory_type', '?')}) {r['content'][:200]}")
|
||||
return "\n".join(parts) if parts else "No memories found."
|
||||
|
||||
|
||||
async def list_documents(status: str | None = None) -> str:
|
||||
"""List all indexed documents."""
|
||||
docs = await db.list_documents(status=status)
|
||||
parts = []
|
||||
for d in docs:
|
||||
parts.append(f"- [{d['status']}] {d['filename']} (p{d.get('page_count', '?')}, {d['created_at']})")
|
||||
return "\n".join(parts) if parts else "No documents found."
|
||||
|
||||
|
||||
async def save_finding(session_id: str, question: str, answer: str,
|
||||
summary: str, confidence: float = 0.8,
|
||||
relevant_chunks: list | None = None) -> str:
|
||||
"""Save a research finding to the database."""
|
||||
finding_id = await db.store_finding(
|
||||
session_id, question, answer, summary,
|
||||
"researcher", confidence, relevant_chunks
|
||||
)
|
||||
await db.update_session(session_id, findings={"count": 1})
|
||||
return finding_id
|
||||
|
||||
|
||||
async def save_structured_evidence(
|
||||
session_id: str, rows: list[dict]
|
||||
) -> list[str]:
|
||||
"""Save evidence-extraction rows with traceability fields."""
|
||||
ids = await db.save_structured_evidence(session_id, rows)
|
||||
return ids
|
||||
|
||||
|
||||
async def get_pipeline_state(session_id: str) -> list[dict]:
|
||||
"""Get saved pipeline intermediate stages."""
|
||||
return await db.get_pipeline_stages(session_id)
|
||||
|
||||
|
||||
async def merge_evidence(rows_a: list[dict], rows_b: list[dict]) -> list[dict]:
|
||||
"""Merge two evidence sets, flagging agreement/conflict by topic."""
|
||||
by_topic: dict[str, list] = {}
|
||||
for r in rows_a + rows_b:
|
||||
topic = r.get("topic", "unknown")
|
||||
by_topic.setdefault(topic, []).append(r)
|
||||
merged = []
|
||||
for topic, rs in by_topic.items():
|
||||
if len(rs) == 2:
|
||||
merged.append({
|
||||
"topic": topic,
|
||||
"source_a": rs[0].get("evidence", ""),
|
||||
"source_b": rs[1].get("evidence", ""),
|
||||
"agreement": "Yes" if rs[0].get("evidence") == rs[1].get("evidence") else "No",
|
||||
"conflict": "Yes" if rs[0].get("confidence") != rs[1].get("confidence") else "No",
|
||||
"notes": f"Combined from {rs[0].get('source_doc','')} and {rs[1].get('source_doc','')}",
|
||||
})
|
||||
else:
|
||||
merged.append({k: rs[0].get(k, "") for k in ("topic", "description", "trace_ref", "evidence", "confidence")})
|
||||
return merged
|
||||
|
||||
|
||||
# Tool registry
|
||||
TOOLS = {
|
||||
"read_document": read_document,
|
||||
"read_chunks": read_chunks,
|
||||
"get_page_text": get_page_text,
|
||||
"vector_search": vector_search,
|
||||
"text_search": text_search,
|
||||
"extract_facts": extract_facts,
|
||||
"get_polygon_view": get_polygon_view,
|
||||
"get_memories": get_memories,
|
||||
"memory_similarity_search": memory_similarity_search,
|
||||
"list_documents": list_documents,
|
||||
"save_finding": save_finding,
|
||||
"save_structured_evidence": save_structured_evidence,
|
||||
"get_pipeline_state": get_pipeline_state,
|
||||
"merge_evidence": merge_evidence,
|
||||
}
|
||||
Reference in New Issue
Block a user