mmap-worker / app /workers /tasks.py
jugalgajjar's picture
feat(graph): two-tier L3 alignment thresholds + reconcile co-mention hint — same-type 0.85, cross-type 0.92
9d1cf4d
Raw
History Blame Contribute Delete
18.5 kB
"""arq tasks. Heavy ML imports live in app.workers.ocr.* (worker image only)."""
import asyncio
import logging
from typing import Any
from uuid import uuid4
from sqlalchemy import select
from app.core.config import settings
from app.db.session import async_session_maker
from app.documents.chunks_model import DocumentChunk
from app.documents.models import Document, DocumentStatus
from app.storage.minio_client import get_minio_client
log = logging.getLogger("mmap.worker")
async def process_document_ocr(ctx: dict[str, Any], document_id: str) -> str:
# OCR → chunk → embed → Qdrant → graph → summary.
from app.embeddings import embed_texts
from app.workers.chunking import chunk_text, is_meaningful
from app.workers.ocr.pipeline import extract_text_from_bytes
async with async_session_maker() as db:
doc = await db.get(Document, document_id)
if doc is None:
log.warning("process_document_ocr: doc %s not found", document_id)
return "missing"
doc.status = DocumentStatus.PROCESSING
await db.commit()
await db.refresh(doc)
try:
data = await asyncio.to_thread(_download_bytes, doc.storage_key)
text = await extract_text_from_bytes(
data,
doc.content_type,
filename=doc.filename,
)
doc.extracted_text = text
raw_chunks = chunk_text(text) if text.strip() else []
# Drop OCR/PDF noise that would otherwise pollute retrieval. Only
# filter when the doc produced more than one chunk — for short
# transcripts (audio, voice notes) we'd rather index a small chunk
# than nothing.
if len(raw_chunks) > 1:
chunks = [c for c in raw_chunks if is_meaningful(c.text)]
else:
chunks = raw_chunks
log.info(
"doc=%s ocr=%d chars chunks=%d (filtered %d noise)",
document_id,
len(text),
len(chunks),
len(raw_chunks) - len(chunks),
)
chunk_rows: list[DocumentChunk] = []
for c in chunks:
row = DocumentChunk(
id=uuid4(),
document_id=doc.id,
chunk_index=c.index,
text=c.text,
char_start=c.char_start,
char_end=c.char_end,
)
chunk_rows.append(row)
db.add(row)
await db.flush()
if chunk_rows:
vectors = await asyncio.to_thread(embed_texts, [r.text for r in chunk_rows])
await asyncio.to_thread(
_upsert_qdrant_points,
chunk_rows=chunk_rows,
vectors=vectors,
user_id=str(doc.user_id),
document_id=str(doc.id),
)
doc.status = DocumentStatus.PROCESSED
await db.commit()
# Graph + summary are best-effort. Vector RAG still works without
# them, so failures here must not fail the document.
if text.strip():
await _ingest_graph(
chunks=[r.text for r in chunk_rows] or [text],
user_id=str(doc.user_id),
document_id=str(doc.id),
)
await _summarize_and_store(text=text, document_id=str(doc.id))
return "processed"
except (Exception, asyncio.CancelledError) as exc:
# asyncio.CancelledError is a BaseException in 3.8+, so plain
# `except Exception` missed it — arq's job-timeout cancel would
# bypass this handler entirely and leave the doc row wedged at
# `processing` forever. Catch both and re-raise CancelledError
# after our cleanup so arq still records the timeout correctly.
timed_out = isinstance(exc, asyncio.CancelledError)
log.exception("ocr pipeline failed for doc=%s", document_id)
# Discard pending chunk inserts so we don't persist orphan rows
# whose vectors never reached Qdrant.
try:
await db.rollback()
failed_doc = await db.get(Document, document_id)
if failed_doc is not None:
failed_doc.status = DocumentStatus.FAILED
failed_doc.error_message = (
"OCR timed out — try a smaller or text-based PDF."
if timed_out
else str(exc)
)
await db.commit()
except Exception:
log.exception("failed to record failure status for doc=%s", document_id)
if timed_out:
# Let arq see the cancel so its own bookkeeping stays right.
raise
return "failed"
def _download_bytes(storage_key: str) -> bytes:
client = get_minio_client()
response = client.get_object(settings.minio_bucket, storage_key)
try:
return response.read()
finally:
response.close()
response.release_conn()
def _upsert_qdrant_points(
*,
chunk_rows: list[DocumentChunk],
vectors: list[list[float]],
user_id: str,
document_id: str,
) -> None:
from qdrant_client.http import models as qmodels
from app.rag.sparse import SPARSE_VECTOR_NAME, encode_passages
from app.storage.qdrant_client import (
COLLECTION_NAME,
DENSE_VECTOR_NAME,
ensure_collection,
get_qdrant_client,
)
ensure_collection()
client = get_qdrant_client()
sparse_vectors = encode_passages([row.text for row in chunk_rows])
points = [
qmodels.PointStruct(
id=str(row.id),
vector={
DENSE_VECTOR_NAME: vec,
SPARSE_VECTOR_NAME: qmodels.SparseVector(indices=s_idx, values=s_val),
},
payload={
"chunk_id": str(row.id),
"document_id": document_id,
"user_id": user_id,
"chunk_index": row.chunk_index,
"text": row.text,
},
)
for row, vec, (s_idx, s_val) in zip(chunk_rows, vectors, sparse_vectors, strict=True)
]
client.upsert(collection_name=COLLECTION_NAME, points=points, wait=True)
GRAPH_EXTRACT_MAX_ATTEMPTS = 3
GRAPH_EXTRACT_BACKOFF_SECONDS = (15, 30) # sleep between attempt 1→2 and 2→3
async def _ingest_graph(*, chunks: list[str], user_id: str, document_id: str) -> None:
# Best-effort: swallow errors so the worker stays non-blocking.
try:
from app.embeddings import embed_texts
from app.graph.alignment import (
AlignedEntity,
AlignedRelationship,
Candidate,
align_batch,
)
from app.graph.extraction import safe_extract_entities
from app.graph.neo4j_client import (
ensure_indexes,
list_entity_candidates,
list_entity_semantic_candidates,
upsert_entity,
upsert_relationship,
)
from app.graph.semantic_alignment import (
SemanticCandidate,
find_semantic_alias,
format_entity_text,
)
# Groq's json_object validator flakes intermittently — the same
# extraction that returned 0 entities on upload will often return
# 20+ on a manual reindex 60-120s later. Retry transient failures
# in-line so the user's graph populates without a manual click.
outcome = await safe_extract_entities(chunks)
for attempt in range(2, GRAPH_EXTRACT_MAX_ATTEMPTS + 1):
if outcome.result.entities or not outcome.transient_failure:
break
sleep_for = GRAPH_EXTRACT_BACKOFF_SECONDS[attempt - 2]
log.info(
"graph: transient extraction failure on doc=%s (attempt %d/%d) — sleeping %ds",
document_id,
attempt - 1,
GRAPH_EXTRACT_MAX_ATTEMPTS,
sleep_for,
)
await asyncio.sleep(sleep_for)
outcome = await safe_extract_entities(chunks)
if not outcome.result.entities:
log.info(
"graph: no entities extracted for doc=%s (transient=%s)",
document_id,
outcome.transient_failure,
)
return
result = outcome.result
await ensure_indexes()
# Alignment (#43a): pull the user's existing entities as fuzzy-match
# candidates so this doc's writes converge on the shared KG instead
# of creating parallel islands ("GWU" vs "gwu.", "Kamalasankaris" vs
# "Kamalasankari" etc.). One Neo4j fetch per doc; the aligner runs
# in-memory from there.
existing_pairs = await list_entity_candidates(user_id)
existing = [Candidate(name_lower=nl, type=t) for nl, t in existing_pairs]
aligned_entities, aligned_rels = align_batch(
result.entities,
result.relationships,
existing,
)
pre_l3_ent_count = len(aligned_entities)
pre_l3_rel_count = len(aligned_rels)
# Semantic alignment (#43c): L3 layer on top of L1/L2. Embed each
# aligned entity's `name: description`, compare cosine similarity
# against existing same-type entities' stored embeddings. If a
# match clears the threshold, rewrite the entity's name_lower to
# the existing canonical form and cascade the change through the
# relationships. Catches abbreviation/expansion pairs (SFT ↔
# Supervised Fine-Tuning) that L1/L2 string-fuzzy can't.
entity_embeddings: dict[str, list[float]] = {}
if settings.graph_semantic_align and aligned_entities:
existing_semantic = await list_entity_semantic_candidates(user_id)
running_candidates = [
SemanticCandidate(
name_lower=nl,
type=t,
embedding=tuple(emb),
)
for nl, t, emb in existing_semantic
]
texts = [format_entity_text(e.name, e.description) for e in aligned_entities]
embeddings = await asyncio.to_thread(embed_texts, texts)
alias_map: dict[str, str] = {} # old name_lower → canonical
for e, emb in zip(aligned_entities, embeddings, strict=True):
match = find_semantic_alias(
emb,
e.type,
running_candidates,
settings.graph_semantic_threshold_same,
settings.graph_semantic_threshold_cross,
)
if match and match != e.name_lower:
alias_map[e.name_lower] = match
else:
# Not aliased — this entity becomes a candidate that
# later same-batch entities (or future docs) can align to.
running_candidates.append(
SemanticCandidate(
name_lower=e.name_lower,
type=e.type,
embedding=tuple(emb),
)
)
entity_embeddings[e.name_lower] = emb
if alias_map:
seen: set[str] = set()
collapsed: list[AlignedEntity] = []
for e in aligned_entities:
canonical = alias_map.get(e.name_lower, e.name_lower)
if canonical in seen:
continue
seen.add(canonical)
if canonical == e.name_lower:
collapsed.append(e)
else:
# Aliased into a canonical form — keep the aliased
# display name; ON CREATE on the existing node is
# a no-op so display stays as first-write.
collapsed.append(
AlignedEntity(
name=e.name,
name_lower=canonical,
type=e.type,
description=e.description,
)
)
aligned_entities = collapsed
rewritten_rels: list[AlignedRelationship] = []
for r in aligned_rels:
src = alias_map.get(r.source_lower, r.source_lower)
tgt = alias_map.get(r.target_lower, r.target_lower)
if src == tgt:
continue # self-loop after L3
rewritten_rels.append(
AlignedRelationship(
source_lower=src,
target_lower=tgt,
relation=r.relation,
)
)
aligned_rels = rewritten_rels
for e in aligned_entities:
await upsert_entity(
user_id=user_id,
document_id=document_id,
name=e.name,
name_lower=e.name_lower,
entity_type=e.type,
description=e.description,
embedding=entity_embeddings.get(e.name_lower),
)
for r in aligned_rels:
await upsert_relationship(
user_id=user_id,
document_id=document_id,
source_lower=r.source_lower,
target_lower=r.target_lower,
relation=r.relation,
)
log.info(
"graph: doc=%s chunks=%d entities=%d rels=%d "
"(pre-l3 %d ents, %d rels; raw %d ents, %d rels)",
document_id,
len(chunks),
len(aligned_entities),
len(aligned_rels),
pre_l3_ent_count,
pre_l3_rel_count,
len(result.entities),
len(result.relationships),
)
except Exception as exc: # noqa: BLE001
log.warning("graph ingest failed (non-blocking): %s", exc)
async def _summarize_and_store(*, text: str, document_id: str) -> None:
# Best-effort: failure leaves the existing summary columns untouched.
try:
from app.agents.summarization import summarize_document
result = await summarize_document(text)
if result.is_empty():
log.info("summary: no content produced for doc=%s", document_id)
return
async with async_session_maker() as db:
doc = await db.get(Document, document_id)
if doc is None:
return
doc.summary_tldr = result.tldr or None
doc.summary_key_points = list(result.key_points) or None
doc.summary_topics = list(result.topics) or None
await db.commit()
log.info(
"summary: doc=%s tldr=%d chars points=%d topics=%d",
document_id,
len(result.tldr),
len(result.key_points),
len(result.topics),
)
except Exception as exc: # noqa: BLE001
log.warning("summarization failed (non-blocking): %s", exc)
async def resummarize_document(ctx: dict[str, Any], document_id: str) -> str:
# Re-run summarization on stored chunks. No re-OCR or re-embed.
async with async_session_maker() as db:
doc = await db.get(Document, document_id)
if doc is None:
return "missing"
if str(doc.status) != DocumentStatus.PROCESSED.value:
log.info(
"resummarize: doc %s status=%s — skipping (expected 'processed')",
document_id,
doc.status,
)
return "skipped"
stmt = (
select(DocumentChunk)
.where(DocumentChunk.document_id == doc.id)
.order_by(DocumentChunk.chunk_index.asc())
)
rows = (await db.execute(stmt)).scalars().all()
text = "\n\n".join(r.text for r in rows) if rows else (doc.extracted_text or "")
if not text.strip():
log.info("resummarize: doc %s has no text — nothing to summarize", document_id)
return "no-text"
await _summarize_and_store(text=text, document_id=document_id)
return "resummarized"
async def reindex_graph_for_document(ctx: dict[str, Any], document_id: str) -> str:
# Re-run entity extraction on stored chunks. No re-OCR or re-embed.
async with async_session_maker() as db:
doc = await db.get(Document, document_id)
if doc is None:
log.warning("reindex_graph: doc %s not found", document_id)
return "missing"
if str(doc.status) != DocumentStatus.PROCESSED.value:
log.info(
"reindex_graph: doc %s status=%s — skipping (expected 'processed')",
document_id,
doc.status,
)
return "skipped"
stmt = (
select(DocumentChunk)
.where(DocumentChunk.document_id == doc.id)
.order_by(DocumentChunk.chunk_index.asc())
)
rows = (await db.execute(stmt)).scalars().all()
# Prefer stored chunk rows; fall back to extracted_text as a single
# chunk for legacy docs indexed before chunking existed.
if rows:
chunk_texts = [r.text for r in rows]
elif doc.extracted_text and doc.extracted_text.strip():
chunk_texts = [doc.extracted_text]
else:
chunk_texts = []
if not any(t.strip() for t in chunk_texts):
log.info("reindex_graph: doc %s has no text — nothing to extract", document_id)
return "no-text"
await _ingest_graph(
chunks=chunk_texts,
user_id=str(doc.user_id),
document_id=str(doc.id),
)
return "reindexed"
async def fetch_document(document_id: str) -> Document | None:
# Test helper; not part of arq's surface.
async with async_session_maker() as db:
result = await db.execute(select(Document).where(Document.id == document_id))
return result.scalar_one_or_none()