"""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, }