Spaces:
Runtime error
Runtime error
File size: 8,424 Bytes
b76f199 732b14f b76f199 732b14f b76f199 732b14f b76f199 732b14f b76f199 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 | """Agent tool surface: wrap product features as named, documented capabilities.
The REST API exposes many flows (upload, generate, similar content, canonical
scan, …). The agentic HeadAgent should depend on this layer rather than
reaching straight into retrieval helpers, so each capability has a stable name
and schema for future LLM ``tool_calls`` / ``bind_tools`` integration.
When ``inspector_tool_agent`` is enabled and an API key is set, the inspector
(:mod:`app.agentic.inspector_loop`) invokes these implementations from an OpenAI
``tool_calls`` loop so the model chooses order and queries. The legacy HeadAgent
path still calls the same functions in a fixed order when the key is missing or
the setting is false (e.g. CI / mock adapter).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.schemas import SearchResult, SimilarContentRequest, SimilarContentResponse
from app.retrieval.retriever import retrieve_for_report, retrieve_for_report_async
from app.retrieval.reranker import rerank as jaccard_rerank
from app.retrieval.vector_search import async_vs_search
from app.services.content_similarity import scan_similar_content
from app.vectorstore.factory import get_vectorstore
@dataclass(frozen=True)
class ToolSpec:
"""OpenAI-style tool metadata (subset of JSON Schema)."""
name: str
description: str
parameters: dict[str, Any]
def list_tool_specs() -> list[ToolSpec]:
"""Return tool definitions for documentation or future ``bind_tools``."""
return [
ToolSpec(
name="retrieve_tenant_evidence",
description=(
"Semantic search over the tenant's indexed survey and reference uploads, "
"prioritising the report's primary document. Returns reranked text chunks with scores."
),
parameters={
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query (e.g. joined inspection bullets)."},
"tenant_id": {"type": "string"},
"primary_document_id": {"type": "string"},
"secondary_document_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Optional additional upload UUIDs to include in routing.",
},
"k": {"type": "integer", "minimum": 1, "maximum": 80},
"rerank_top_n": {"type": "integer", "minimum": 1, "maximum": 20},
},
"required": ["query", "tenant_id", "primary_document_id", "k", "rerank_top_n"],
},
),
ToolSpec(
name="retrieve_kb_guidance",
description=(
"Search the local RICS standards / exemplar knowledge base (reserved tenant). "
"Use for professional wording and compliance context; chunks may carry kb_path metadata."
),
parameters={
"type": "object",
"properties": {
"query": {"type": "string"},
"k": {"type": "integer"},
"rerank_top_n": {"type": "integer", "description": "How many KB chunks to keep after reranking."},
"hierarchy_level": {
"type": "string",
"enum": ["document", "section", "paragraph"],
"description": "Optional strict hierarchy filter for chunk granularity.",
},
},
"required": ["query", "k"],
},
),
ToolSpec(
name="find_similar_library_and_peers",
description=(
"Find semantically similar indexed library chunks and lexical overlaps with other "
"sections' draft notes (corpus hygiene / deduplication)."
),
parameters={
"type": "object",
"properties": {
"tenant_id": {"type": "string"},
"text": {"type": "string"},
"section_code": {"type": "string"},
"peer_sections": {"type": "object", "additionalProperties": {"type": "string"}},
"exclude_document_ids": {"type": "array", "items": {"type": "string"}},
"limit": {"type": "integer"},
"min_relevance_percent": {"type": "number"},
},
"required": ["tenant_id", "text"],
},
),
]
def retrieve_tenant_evidence(
*,
query: str,
tenant_id: str,
primary_document_id: str,
secondary_document_ids: list[str] | None,
k: int,
rerank_top_n: int,
) -> list[SearchResult]:
"""Tool: tenant-scoped retrieval + rerank (same stack as interactive generate)."""
candidates = retrieve_for_report(
query=query,
tenant_id=tenant_id,
primary_document_id=primary_document_id,
secondary_document_ids=list(secondary_document_ids or []),
k=k,
)
return list(jaccard_rerank(query=query, results=candidates, top_n=rerank_top_n))
async def retrieve_tenant_evidence_async(
*,
query: str,
tenant_id: str,
primary_document_id: str,
secondary_document_ids: list[str] | None,
k: int,
rerank_top_n: int,
) -> list[SearchResult]:
"""Async tenant retrieval + rerank (semantic cache when Qdrant enabled)."""
from app.retrieval.retriever import retrieve_for_report_unified
candidates = await retrieve_for_report_unified(
query=query,
tenant_id=tenant_id,
primary_document_id=primary_document_id,
k=k,
secondary_document_ids=list(secondary_document_ids or []),
)
return list(jaccard_rerank(query=query, results=candidates, top_n=rerank_top_n))
def retrieve_kb_guidance(
*,
query: str,
k: int,
hierarchy_level: str | None,
rerank_top_n: int = 5,
) -> list[SearchResult]:
"""Tool: knowledge-base retrieval + rerank (no-op when KB disabled)."""
if not settings.knowledge_base_enabled:
return []
vs = get_vectorstore()
hl = hierarchy_level if hierarchy_level in ("document", "section", "paragraph") else None
kb_hits = vs.search(
query=query,
tenant_id=settings.knowledge_base_tenant_id,
k=k,
hierarchy_level=hl,
doc_id_in=None,
)
return list(jaccard_rerank(query=query, results=kb_hits, top_n=rerank_top_n))
async def retrieve_kb_guidance_async(
*,
query: str,
k: int,
hierarchy_level: str | None,
rerank_top_n: int = 5,
) -> list[SearchResult]:
"""Async KB retrieval + rerank."""
if not settings.knowledge_base_enabled:
return []
vs = get_vectorstore()
hl = hierarchy_level if hierarchy_level in ("document", "section", "paragraph") else None
kb_hits = await async_vs_search(
vs,
query,
settings.knowledge_base_tenant_id,
k=k,
hierarchy_level=hl,
)
return list(jaccard_rerank(query=query, results=kb_hits, top_n=rerank_top_n))
def dedupe_search_results(results: list[SearchResult]) -> list[SearchResult]:
"""Tool helper: dedupe by chunk_id preserving first-seen order."""
seen: set[str] = set()
out: list[SearchResult] = []
for r in results:
if r.chunk_id in seen:
continue
seen.add(r.chunk_id)
out.append(r)
return out
async def find_similar_library_and_peers(
db: AsyncSession,
tenant_id: str,
*,
text: str,
section_code: str | None = None,
peer_sections: dict[str, str] | None = None,
exclude_document_ids: list[str] | None = None,
limit: int = 8,
min_relevance_percent: float = 28.0,
) -> SimilarContentResponse:
"""Tool: wraps ``POST /content/similar`` logic without an HTTP round-trip."""
body = SimilarContentRequest(
text=text,
section_code=section_code,
peer_sections=dict(peer_sections or {}),
exclude_document_ids=list(exclude_document_ids or []),
limit=limit,
min_relevance_percent=min_relevance_percent,
)
return await scan_similar_content(db, tenant_id, body)
|