Add LLM token
This commit is contained in:
@@ -51,19 +51,36 @@ def _extract_json(text: str):
|
||||
|
||||
|
||||
def extract_text_from_doc_id(doc_id: str) -> str:
|
||||
"""Fetch the full text of a document by retrieving its chunks filtered by doc_id.
|
||||
|
||||
Uses a high top_k and doc_id filter to reconstruct the document in chunk order,
|
||||
avoiding the previous approach of semantic search by doc_name which could return
|
||||
chunks from unrelated documents.
|
||||
"""
|
||||
from app.shared.bootstrap import get_document_query_service, get_retrieval_service
|
||||
doc = get_document_query_service().get(doc_id)
|
||||
if not doc:
|
||||
raise ValueError(f"Document '{doc_id}' not found")
|
||||
service = get_retrieval_service()
|
||||
chunks = service.retrieve(query=doc.doc_name, top_k=30)
|
||||
doc_chunks = [c for c in chunks if c.doc_id == doc_id]
|
||||
# Use doc_name as a broad query, filter strictly by doc_id so we only get
|
||||
# this document's chunks; top_k=100 covers most real-world documents.
|
||||
chunks = service.retrieve(query=doc.doc_name, top_k=100, filters=doc_id)
|
||||
doc_chunks = [c for c in chunks if getattr(c, "doc_id", None) == doc_id]
|
||||
if not doc_chunks:
|
||||
doc_chunks = chunks[:15]
|
||||
return "\n\n".join(c.text for c in doc_chunks[:15])
|
||||
# Fallback: use top results even without doc_id match (e.g., legacy store)
|
||||
doc_chunks = chunks[:30]
|
||||
# Sort by chunk_index to preserve document reading order
|
||||
doc_chunks.sort(key=lambda c: getattr(c, "chunk_index", 0))
|
||||
return "\n\n".join(c.text for c in doc_chunks[:40])
|
||||
|
||||
|
||||
def extract_text_from_file(content: bytes, filename: str) -> str:
|
||||
"""Parse an uploaded file and return its full text content.
|
||||
|
||||
Removed previous 4000-char cap so large specifications and standards are
|
||||
fully analysed. The caller is responsible for splitting the text into
|
||||
clause-sized chunks before passing to the LLM.
|
||||
"""
|
||||
from app.shared.bootstrap import get_document_command_service
|
||||
suffix = os.path.splitext(filename or "doc.pdf")[1] or ".pdf"
|
||||
tmp_path = ""
|
||||
@@ -74,10 +91,11 @@ def extract_text_from_file(content: bytes, filename: str) -> str:
|
||||
service = get_document_command_service()
|
||||
parsed = service.parser.parse(file_path=tmp_path, doc_id="tmp_analysis", doc_name=filename)
|
||||
if parsed.raw_text:
|
||||
return parsed.raw_text[:4000]
|
||||
# Return full text — truncation happens in split_into_clauses()
|
||||
return parsed.raw_text
|
||||
return "\n".join(
|
||||
b.get("text", "") for b in parsed.semantic_blocks[:30] if b.get("text")
|
||||
)[:4000]
|
||||
b.get("text", "") for b in parsed.semantic_blocks if b.get("text")
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("File text extraction failed: {}", exc)
|
||||
return ""
|
||||
@@ -88,27 +106,68 @@ def extract_text_from_file(content: bytes, filename: str) -> str:
|
||||
|
||||
|
||||
def split_into_clauses(text: str, client: "BaseLLMClient") -> list[str]:
|
||||
prompt = (
|
||||
"You are a compliance analysis expert. Split the following text into 3-8 "
|
||||
"semantically complete compliance clauses. Each clause should be an independent "
|
||||
"compliance requirement or technical statement.\n"
|
||||
"Return as JSON array of strings, e.g.:\n"
|
||||
'["Clause one...", "Clause two..."]\n'
|
||||
"Return ONLY the JSON array.\n\n"
|
||||
f"Text:\n{text[:2000]}"
|
||||
)
|
||||
response = client.chat([{"role": "user", "content": prompt}], max_tokens=1000)
|
||||
if response.is_success:
|
||||
try:
|
||||
result = _extract_json(response.content)
|
||||
if isinstance(result, list):
|
||||
clauses = [str(c).strip() for c in result if str(c).strip()]
|
||||
if clauses:
|
||||
return clauses[:8]
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("Clause split JSON parse failed, using fallback")
|
||||
sentences = re.split(r"[.?!;\n]+", text)
|
||||
return [s.strip() for s in sentences if len(s.strip()) > 20][:6]
|
||||
"""Split a compliance document into semantically independent clauses.
|
||||
|
||||
For long texts (> 2 000 chars) the document is processed in overlapping
|
||||
2 000-char windows so no content is missed. Each window produces up to 4
|
||||
clauses; results are deduplicated and capped at 12 total to keep analysis
|
||||
latency reasonable.
|
||||
"""
|
||||
# Window size and step for sliding-window clause extraction
|
||||
_WINDOW = 2000
|
||||
_STEP = 1800 # 200-char overlap to avoid cutting clauses at boundaries
|
||||
_MAX_CLAUSES = 12
|
||||
|
||||
windows = []
|
||||
if len(text) <= _WINDOW:
|
||||
windows = [text]
|
||||
else:
|
||||
pos = 0
|
||||
while pos < len(text):
|
||||
windows.append(text[pos: pos + _WINDOW])
|
||||
pos += _STEP
|
||||
|
||||
all_clauses: list[str] = []
|
||||
for window in windows:
|
||||
prompt = (
|
||||
"You are a compliance analysis expert. Split the following text into "
|
||||
"3-4 semantically complete compliance clauses. Each clause must be an "
|
||||
"independent requirement or technical statement. Omit section headings, "
|
||||
"definitions, and non-normative text.\n"
|
||||
"Return as JSON array of strings, e.g.:\n"
|
||||
'["Clause one...", "Clause two..."]\n'
|
||||
"Return ONLY the JSON array.\n\n"
|
||||
f"Text:\n{window}"
|
||||
)
|
||||
response = client.chat([{"role": "user", "content": prompt}], max_tokens=800)
|
||||
if response.is_success:
|
||||
try:
|
||||
result = _extract_json(response.content)
|
||||
if isinstance(result, list):
|
||||
clauses = [str(c).strip() for c in result if str(c).strip()]
|
||||
all_clauses.extend(clauses[:4])
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("Clause split JSON parse failed for window, using sentence fallback")
|
||||
sentences = re.split(r"[.?!;\n]+", window)
|
||||
all_clauses.extend(s.strip() for s in sentences if len(s.strip()) > 20)
|
||||
else:
|
||||
# LLM unavailable — fall back to sentence splitting for this window
|
||||
sentences = re.split(r"[.?!;\n]+", window)
|
||||
all_clauses.extend(s.strip() for s in sentences if len(s.strip()) > 20)
|
||||
|
||||
if len(all_clauses) >= _MAX_CLAUSES:
|
||||
break
|
||||
|
||||
# Deduplicate near-duplicates (same first 80 chars) that span window boundaries
|
||||
seen: set[str] = set()
|
||||
deduped: list[str] = []
|
||||
for c in all_clauses:
|
||||
key = c[:80].lower()
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
deduped.append(c)
|
||||
|
||||
return deduped[:_MAX_CLAUSES]
|
||||
|
||||
|
||||
def retrieve_for_clause(
|
||||
@@ -117,7 +176,33 @@ def retrieve_for_clause(
|
||||
top_k: int = 5,
|
||||
domains: str | None = None,
|
||||
) -> list["RetrievedChunk"]:
|
||||
return retrieval_service.retrieve(query=clause, top_k=top_k, filters=domains)
|
||||
"""Retrieve regulation chunks relevant to a clause.
|
||||
|
||||
If the best retrieval score is below 0.55, rewrite the clause into a more
|
||||
technical query and retry once to improve coverage.
|
||||
"""
|
||||
chunks = retrieval_service.retrieve(query=clause, top_k=top_k, filters=domains)
|
||||
if not chunks:
|
||||
return chunks
|
||||
|
||||
best_score = max((getattr(c, "score", 0) for c in chunks), default=0)
|
||||
if best_score < 0.55:
|
||||
# Rewrite clause as technical keyword query and retry
|
||||
keywords = " ".join(
|
||||
w for w in re.split(r"\W+", clause) if len(w) > 3
|
||||
)[:200]
|
||||
retry_chunks = retrieval_service.retrieve(query=keywords, top_k=top_k, filters=domains)
|
||||
if retry_chunks:
|
||||
# Merge: keep unique chunks, prefer higher-score version
|
||||
seen_ids: set[str] = {getattr(c, "chunk_id", str(i)) for i, c in enumerate(chunks)}
|
||||
for rc in retry_chunks:
|
||||
rid = getattr(rc, "chunk_id", "")
|
||||
if rid not in seen_ids:
|
||||
chunks.append(rc)
|
||||
seen_ids.add(rid)
|
||||
chunks.sort(key=lambda c: getattr(c, "score", 0), reverse=True)
|
||||
chunks = chunks[:top_k]
|
||||
return chunks
|
||||
|
||||
|
||||
def process_single_clause(
|
||||
@@ -130,14 +215,75 @@ def process_single_clause(
|
||||
) -> dict:
|
||||
"""Process one clause: retrieve relevant regulations then check compliance.
|
||||
|
||||
Returns a dict with keys: index, chunks, finding (may be None on LLM failure).
|
||||
Returns a dict with keys:
|
||||
- index: clause position (for ordering)
|
||||
- chunks: list of RetrievedChunk (for source events)
|
||||
- finding: dict with title/desc/status/clause_ref/confidence (may be None on LLM failure)
|
||||
|
||||
Designed to run inside asyncio.to_thread() for parallel execution.
|
||||
The finding now includes a 'source_refs' list linking back to the chunks
|
||||
that informed the verdict, enabling the frontend to correlate sources with findings.
|
||||
"""
|
||||
chunks = retrieve_for_clause(clause, retrieval_service, top_k, domains)
|
||||
finding = check_clause_compliance(clause, chunks, client)
|
||||
if finding is not None:
|
||||
# Attach source references so the frontend can link finding ↔ sources
|
||||
finding["source_refs"] = [
|
||||
{
|
||||
"standard": getattr(c, "doc_title", "") or getattr(c, "doc_name", ""),
|
||||
"clause": getattr(c, "section_title", "") or "",
|
||||
"score": round(float(getattr(c, "score", 0)), 3),
|
||||
}
|
||||
for c in chunks[:3]
|
||||
]
|
||||
return {"index": index, "chunks": chunks, "finding": finding}
|
||||
|
||||
|
||||
async def run_clauses_streaming(
|
||||
clauses: list[str],
|
||||
retrieval_service: "KnowledgeRetrievalService",
|
||||
client: "BaseLLMClient",
|
||||
top_k: int = 5,
|
||||
domains: str | None = None,
|
||||
):
|
||||
"""Process all clauses concurrently and yield each result as it completes.
|
||||
|
||||
Unlike the old gather()-based approach, this uses asyncio.Queue so that
|
||||
findings are emitted to the SSE stream immediately when each clause
|
||||
finishes — the user sees results progressively rather than waiting for
|
||||
the slowest clause before seeing any output.
|
||||
|
||||
Yields dicts with keys: index, chunks, finding (same schema as
|
||||
process_single_clause, plus a sentinel {"_done": True} at the end).
|
||||
"""
|
||||
queue: asyncio.Queue[dict] = asyncio.Queue()
|
||||
total = len(clauses)
|
||||
|
||||
async def _worker(clause: str, i: int) -> None:
|
||||
"""Run one clause in a thread and push the result into the queue."""
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
process_single_clause,
|
||||
clause, i, retrieval_service, client, top_k, domains,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Clause {} processing failed: {}", i, exc)
|
||||
result = {"index": i, "chunks": [], "finding": None}
|
||||
await queue.put(result)
|
||||
|
||||
# Launch all workers concurrently
|
||||
tasks = [asyncio.create_task(_worker(clause, i)) for i, clause in enumerate(clauses)]
|
||||
|
||||
received = 0
|
||||
while received < total:
|
||||
result = await queue.get()
|
||||
yield result
|
||||
received += 1
|
||||
|
||||
# Wait for all tasks to complete (they should already be done by now)
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
|
||||
async def run_clauses_parallel(
|
||||
clauses: list[str],
|
||||
retrieval_service: "KnowledgeRetrievalService",
|
||||
@@ -145,31 +291,15 @@ async def run_clauses_parallel(
|
||||
top_k: int = 5,
|
||||
domains: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Run all clauses through retrieve+gap-check in parallel.
|
||||
"""Legacy batch API kept for backward compatibility.
|
||||
|
||||
Results are returned in the original clause order even though processing
|
||||
is concurrent. Exceptions in individual clauses are caught and returned as
|
||||
dicts with finding=None so the stream continues for remaining clauses.
|
||||
|
||||
Both retrieval_service and client must be thread-safe — they are shared
|
||||
across all asyncio.to_thread() calls without locking.
|
||||
Collects all streaming results and returns them sorted by clause index.
|
||||
New code should use run_clauses_streaming() directly.
|
||||
"""
|
||||
tasks = [
|
||||
asyncio.to_thread(
|
||||
process_single_clause,
|
||||
clause, i, retrieval_service, client, top_k, domains,
|
||||
)
|
||||
for i, clause in enumerate(clauses)
|
||||
]
|
||||
raw = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
results = []
|
||||
for i, r in enumerate(raw):
|
||||
if isinstance(r, Exception):
|
||||
logger.warning("Clause {} processing failed: {}", i, r)
|
||||
results.append({"index": i, "chunks": [], "finding": None})
|
||||
else:
|
||||
results.append(r)
|
||||
return results
|
||||
results: list[dict] = []
|
||||
async for result in run_clauses_streaming(clauses, retrieval_service, client, top_k, domains):
|
||||
results.append(result)
|
||||
return sorted(results, key=lambda r: r["index"])
|
||||
|
||||
|
||||
def check_clause_compliance(
|
||||
@@ -177,6 +307,15 @@ def check_clause_compliance(
|
||||
chunks: list["RetrievedChunk"],
|
||||
client: "BaseLLMClient",
|
||||
) -> dict | None:
|
||||
"""Check whether a business clause complies with the retrieved regulations.
|
||||
|
||||
The prompt explicitly instructs the LLM to:
|
||||
- extract clause_ref from the retrieved text (not invent it)
|
||||
- include a confidence score (0-1) reflecting how well the retrieved
|
||||
chunks cover the clause topic
|
||||
|
||||
Returns None only when the LLM call fails after all retries.
|
||||
"""
|
||||
reg_context = "\n".join(
|
||||
f"[{i+1}] {c.doc_title} {c.section_title or ''}: {c.text[:300]}"
|
||||
for i, c in enumerate(chunks[:5])
|
||||
@@ -186,14 +325,17 @@ def check_clause_compliance(
|
||||
"complies with the retrieved regulations.\n\n"
|
||||
f"Business clause:\n{clause}\n\n"
|
||||
f"Retrieved regulations:\n{reg_context}\n\n"
|
||||
"Return JSON:\n"
|
||||
"Return JSON with these exact fields:\n"
|
||||
"{\n"
|
||||
' "status": "ok" | "warn" | "risk",\n'
|
||||
' "title": "Short finding title (max 30 chars)",\n'
|
||||
' "desc": "Description (50-120 chars)",\n'
|
||||
' "clause_ref": "Regulation clause reference e.g. Art.9.1 or Sec.3.1"\n'
|
||||
' "clause_ref": "Exact clause/article reference copied from the retrieved text above, '
|
||||
'e.g. Art.9.1 or Sec.3.1. Use null if no specific clause number appears in the retrieved text.",\n'
|
||||
' "confidence": 0.0-1.0 // how well the retrieved context covers this clause topic\n'
|
||||
"}\n"
|
||||
"status: ok=compliant, warn=gap exists, risk=critical/missing\n"
|
||||
"IMPORTANT: copy clause_ref verbatim from the retrieved text; do NOT invent references.\n"
|
||||
"Return ONLY the JSON object."
|
||||
)
|
||||
|
||||
@@ -216,7 +358,10 @@ def check_clause_compliance(
|
||||
"title": str(result.get("title", "Compliance finding")),
|
||||
"desc": str(result.get("desc", "")),
|
||||
"status": result.get("status", "info"),
|
||||
"clause_ref": result.get("clause_ref"),
|
||||
# None if LLM correctly found no clause number in retrieved text
|
||||
"clause_ref": result.get("clause_ref") or None,
|
||||
# Confidence score helps frontend show retrieval quality indicator
|
||||
"confidence": float(result.get("confidence", 0.5)),
|
||||
}
|
||||
except (ValueError, TypeError) as exc:
|
||||
logger.warning("Gap check JSON parse failed: {}", exc)
|
||||
@@ -368,3 +513,58 @@ def generate_suggestions(
|
||||
except (ValueError, TypeError) as exc:
|
||||
logger.warning("generate_suggestions JSON parse failed: {}", exc)
|
||||
return fallback
|
||||
|
||||
|
||||
def detect_cross_clause_conflicts(
|
||||
findings: list[dict],
|
||||
client: "BaseLLMClient",
|
||||
) -> list[dict]:
|
||||
"""Detect contradictions and missing cross-references across all findings.
|
||||
|
||||
Runs a single LLM call after all per-clause findings are collected.
|
||||
Returns a list of conflict dicts: {type, finding_a, finding_b, desc}.
|
||||
Returns an empty list on LLM failure so the caller can proceed without it.
|
||||
"""
|
||||
if len(findings) < 2:
|
||||
# Need at least 2 findings to compare
|
||||
return []
|
||||
|
||||
findings_text = "\n".join(
|
||||
f"[{i+1}] [{f['status'].upper()}] {f['title']}: {f['desc']}"
|
||||
+ (f" (Ref: {f['clause_ref']})" if f.get("clause_ref") else "")
|
||||
for i, f in enumerate(findings)
|
||||
)
|
||||
prompt = (
|
||||
"You are a compliance expert. Review the following compliance findings from the same document "
|
||||
"and identify any cross-clause issues:\n\n"
|
||||
f"Findings:\n{findings_text}\n\n"
|
||||
"Return JSON array of conflicts (empty array [] if none found):\n"
|
||||
"[\n"
|
||||
" {\n"
|
||||
' "type": "contradiction" | "missing_ref" | "cumulative_risk",\n'
|
||||
' "finding_a": <1-based index>,\n'
|
||||
' "finding_b": <1-based index or null>,\n'
|
||||
' "desc": "Brief description of the cross-clause issue (max 100 chars)"\n'
|
||||
" }\n"
|
||||
"]\n"
|
||||
"Return ONLY the JSON array."
|
||||
)
|
||||
try:
|
||||
response = client.chat([{"role": "user", "content": prompt}], max_tokens=600)
|
||||
if not response.is_success:
|
||||
return []
|
||||
result = _extract_json(response.content)
|
||||
if isinstance(result, list):
|
||||
return [
|
||||
{
|
||||
"type": str(c.get("type", "contradiction")),
|
||||
"finding_a": int(c.get("finding_a", 0)),
|
||||
"finding_b": c.get("finding_b"),
|
||||
"desc": str(c.get("desc", "")),
|
||||
}
|
||||
for c in result
|
||||
if isinstance(c, dict)
|
||||
]
|
||||
except Exception as exc:
|
||||
logger.warning("detect_cross_clause_conflicts failed: {}", exc)
|
||||
return []
|
||||
|
||||
Reference in New Issue
Block a user