UpworkAutomation / app /services /evidence_index.py
ananyajoshi's picture
Deploy app: Docker Space, session-only API keys, cloud file-upload dossier
6303ae6 verified
Raw
History Blame Contribute Delete
28.2 kB
"""Evidence Index.
Turns the raw dossier chunks from :mod:`app.services.dossier_reader` into
a list of source-backed proof points, then synthesises a canonical
freelancer profile from those proof points.
Design rules:
* Every claim keeps a reference to the source file, source type, source
priority, and location. Proposals draw only from this index.
* Unknown but readable files can still yield proof points (for example,
a notes file may contain pricing, a strategy doc may contain target
client details, a transcript may contain strengths).
* Higher-priority sources win the canonical profile slot, but
lower-priority evidence is preserved so it can be cited or flagged as
``superseded`` / ``supporting``.
* Dossier content is treated as untrusted data. Instructions inside
dossier files are never executed and are never echoed verbatim into
logs.
* This module never invents proof points. If a signal is not present
in the source chunk, no proof point is emitted for it. The current
implementation is a non-LLM keyword/JSON scanner placeholder; an
LLM-backed extractor can replace ``_scan_text_chunk`` later without
changing the public ``build`` / ``build_profile`` contracts.
"""
from __future__ import annotations
import hashlib
import re
from typing import Any, Iterable, Optional
from app.models.schemas import (
SOURCE_PRIORITY,
CanonicalFreelancerProfile,
CanonicalProfileField,
ChunkRecord,
ClaimType,
ExtractionConfidence,
ProofPoint,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_SECTION_HEADER = re.compile(r"^\s*(#+\s*[^\n]+|[A-Z][A-Z0-9 \-/]{3,}:?)\s*$")
def _normalize(text: str) -> str:
return " ".join(text.split())
def _evidence_id(file_path: str, location: str, claim_type: str, claim_text: str) -> str:
raw = f"{file_path}|{location}|{claim_type}|{claim_text}"
return "ev_" + hashlib.sha1(raw.encode("utf-8")).hexdigest()[:12]
def _split_paragraphs(text: str) -> list[str]:
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
return paragraphs
def _confidence_for(source_type: str) -> ExtractionConfidence:
priority = SOURCE_PRIORITY.get(source_type, 99)
if priority <= 4:
return "high"
if priority <= 10:
return "medium"
return "low"
# ---------------------------------------------------------------------------
# Keyword-based scanners for free-text chunks
# ---------------------------------------------------------------------------
_METRIC_PATTERN = re.compile(
r"\b\d{1,4}(?:[\.,]\d+)?\s*(?:%|percent|x|×)\b"
r"|\$\s?\d{1,3}(?:[,\d]{0,12})(?:\.\d+)?(?:\s?[kKmM])?",
)
_TESTIMONIAL_HINTS = (
"testimonial", "review", "feedback", "client said", "they said",
"would recommend", "highly recommend", "5 stars", "★",
)
_SKILL_LINE_HINTS = ("skills:", "skill:", "tech stack", "stack:", "technologies:")
_TOOL_LINE_HINTS = ("tools:", "toolset:", "software:", "platforms:", "platform:")
_SERVICE_LINE_HINTS = ("services:", "service:", "offerings:", "i offer", "we offer")
_DELIVERABLE_HINTS = ("deliverables:", "deliverable:", "what you get", "you receive")
_TARGET_CLIENT_HINTS = ("target client", "ideal client", "client avatar", "icp")
_OFFER_HINTS = ("selected offer", "primary offer", "headline offer", "offer:", "blueprint:")
_PRICING_HINTS = (
"rate", "rates", "pricing", "package", "retainer", "hourly", "fixed-fee",
"deposit", "fee:", "rate card",
)
_CERTIFICATION_HINTS = ("certified", "certification", "course completion", "credential")
_EDUCATION_HINTS = ("bachelor", "master", "phd", "b.sc", "m.sc", "diploma", "degree")
_LANGUAGE_HINTS = ("languages:", "language:", "fluent in", "native speaker")
_LOCATION_HINTS = ("based in", "located in", "location:", "timezone:", "time zone")
_AVAILABILITY_HINTS = ("availability", "hours per week", "available for")
_WEAKNESS_HINTS = ("limitation", "do not offer", "i don't", "won't accept", "out of scope")
_PORTFOLIO_HINTS = ("portfolio", "case study", "work sample", "project gallery")
_STRENGTH_HINTS = ("strength", "what i do best", "edge", "differentiator")
_POSITIONING_HINTS = ("positioning", "headline", "tagline", "i help", "i help ")
_EXPERIENCE_HINTS = (
"years of experience", "yrs of experience", "years experience",
"experience:", "worked at", "worked with", "previously at",
"led", "managed", "shipped", "delivered for", "built for",
)
_PROJECT_HINTS = ("project:", "case study:", "engagement:", "client project")
def _split_csv_like(value: str) -> list[str]:
return [s.strip() for s in re.split(r"[,;\n•·]+", value) if s.strip()]
def _make_proof(
*,
chunk: ChunkRecord,
claim_type: ClaimType,
claim_text: str,
location: str,
normalized_value: Optional[str] = None,
skills: Iterable[str] = (),
tools: Iterable[str] = (),
industries: Iterable[str] = (),
metrics: Iterable[str] = (),
confidence: Optional[ExtractionConfidence] = None,
) -> ProofPoint:
return ProofPoint(
evidence_id=_evidence_id(chunk.file_path, location, claim_type, claim_text),
source_file=chunk.file_path,
source_type=chunk.source_type,
source_priority=chunk.source_priority,
source_location=location,
claim_type=claim_type,
claim_text=_normalize(claim_text)[:600],
normalized_value=normalized_value,
skills=list(skills),
tools=list(tools),
industries=list(industries),
metrics=list(metrics),
confidence=confidence or _confidence_for(chunk.source_type),
)
def _scan_text_chunk(chunk: ChunkRecord) -> list[ProofPoint]:
"""Pull proof points out of a free-text chunk using keyword heuristics."""
if not chunk.extracted_text:
return []
proofs: list[ProofPoint] = []
text = chunk.extracted_text
base_location = (
f"page {chunk.page_number}" if chunk.page_number is not None else "body"
)
paragraphs = _split_paragraphs(text)
current_section: Optional[str] = chunk.section_name
for idx, paragraph in enumerate(paragraphs):
lower = paragraph.lower()
location = f"{base_location}#para{idx + 1}"
if current_section:
location = f"{current_section}/{location}"
first_line = paragraph.splitlines()[0] if paragraph else ""
if _SECTION_HEADER.match(first_line):
current_section = first_line.strip().strip("#").strip()
def line_after(hint: str) -> Optional[str]:
pos = lower.find(hint)
if pos == -1:
return None
after = paragraph[pos + len(hint):].strip(" :\n")
return after or None
# Metrics / achievements ------------------------------------------------
for m in _METRIC_PATTERN.finditer(paragraph):
proofs.append(
_make_proof(
chunk=chunk,
claim_type="metric",
claim_text=paragraph,
location=location,
normalized_value=m.group(0),
metrics=[m.group(0)],
)
)
# Testimonials ----------------------------------------------------------
if any(hint in lower for hint in _TESTIMONIAL_HINTS) or paragraph.startswith('"'):
proofs.append(
_make_proof(
chunk=chunk,
claim_type="testimonial",
claim_text=paragraph,
location=location,
)
)
# Skills ----------------------------------------------------------------
for hint in _SKILL_LINE_HINTS:
value = line_after(hint)
if value:
items = _split_csv_like(value)
if items:
proofs.append(
_make_proof(
chunk=chunk,
claim_type="skill",
claim_text=value,
location=location,
skills=items,
)
)
break
# Tools -----------------------------------------------------------------
for hint in _TOOL_LINE_HINTS:
value = line_after(hint)
if value:
items = _split_csv_like(value)
if items:
proofs.append(
_make_proof(
chunk=chunk,
claim_type="tool",
claim_text=value,
location=location,
tools=items,
)
)
break
# Services / deliverables ---------------------------------------------
for hint in _SERVICE_LINE_HINTS:
value = line_after(hint)
if value:
proofs.append(
_make_proof(
chunk=chunk,
claim_type="service",
claim_text=value,
location=location,
)
)
break
for hint in _DELIVERABLE_HINTS:
value = line_after(hint)
if value:
proofs.append(
_make_proof(
chunk=chunk,
claim_type="deliverable",
claim_text=value,
location=location,
)
)
break
if any(hint in lower for hint in _TARGET_CLIENT_HINTS):
proofs.append(
_make_proof(
chunk=chunk,
claim_type="target_client",
claim_text=paragraph,
location=location,
)
)
if any(hint in lower for hint in _OFFER_HINTS):
proofs.append(
_make_proof(
chunk=chunk,
claim_type="selected_offer",
claim_text=paragraph,
location=location,
)
)
if any(hint in lower for hint in _PRICING_HINTS):
proofs.append(
_make_proof(
chunk=chunk,
claim_type="pricing",
claim_text=paragraph,
location=location,
)
)
if any(hint in lower for hint in _CERTIFICATION_HINTS):
proofs.append(
_make_proof(
chunk=chunk,
claim_type="certification",
claim_text=paragraph,
location=location,
)
)
if any(hint in lower for hint in _EDUCATION_HINTS):
proofs.append(
_make_proof(
chunk=chunk,
claim_type="education",
claim_text=paragraph,
location=location,
)
)
if any(hint in lower for hint in _LANGUAGE_HINTS):
proofs.append(
_make_proof(
chunk=chunk,
claim_type="language",
claim_text=paragraph,
location=location,
)
)
if any(hint in lower for hint in _LOCATION_HINTS):
# Route timezone phrasing to the dedicated timezone field and
# everything else to location, instead of collapsing both into
# the identity/name field (which previously dropped the data).
is_timezone = ("timezone:" in lower) or ("time zone" in lower)
proofs.append(
_make_proof(
chunk=chunk,
claim_type="timezone" if is_timezone else "location",
claim_text=paragraph,
location=location,
)
)
if any(hint in lower for hint in _AVAILABILITY_HINTS):
proofs.append(
_make_proof(
chunk=chunk,
claim_type="availability",
claim_text=paragraph,
location=location,
)
)
if any(hint in lower for hint in _WEAKNESS_HINTS):
proofs.append(
_make_proof(
chunk=chunk,
claim_type="weakness_or_constraint",
claim_text=paragraph,
location=location,
)
)
if any(hint in lower for hint in _PORTFOLIO_HINTS):
proofs.append(
_make_proof(
chunk=chunk,
claim_type="portfolio",
claim_text=paragraph,
location=location,
)
)
if any(hint in lower for hint in _STRENGTH_HINTS):
proofs.append(
_make_proof(
chunk=chunk,
claim_type="achievement",
claim_text=paragraph,
location=location,
)
)
if any(hint in lower for hint in _POSITIONING_HINTS):
proofs.append(
_make_proof(
chunk=chunk,
claim_type="positioning",
claim_text=paragraph,
location=location,
)
)
if any(hint in lower for hint in _EXPERIENCE_HINTS):
proofs.append(
_make_proof(
chunk=chunk,
claim_type="experience",
claim_text=paragraph,
location=location,
)
)
if any(hint in lower for hint in _PROJECT_HINTS):
proofs.append(
_make_proof(
chunk=chunk,
claim_type="project",
claim_text=paragraph,
location=location,
)
)
if not proofs:
# We still want a placeholder proof from unknown content so that
# the file shows up in the canonical profile's source summary.
proofs.append(
_make_proof(
chunk=chunk,
claim_type="other_relevant_evidence",
claim_text=_normalize(text)[:400],
location=base_location,
confidence="low",
)
)
return proofs
# ---------------------------------------------------------------------------
# JSON scanner
# ---------------------------------------------------------------------------
_JSON_CLAIM_MAP: dict[str, ClaimType] = {
"name": "identity",
"full_name": "identity",
"headline": "positioning",
"title": "positioning",
"positioning": "positioning",
"tagline": "positioning",
"location": "location",
"timezone": "timezone",
"time_zone": "timezone",
"languages": "language",
"selected_offer": "selected_offer",
"offer": "selected_offer",
"guarantee": "guarantee",
"target_client": "target_client",
"ideal_client": "target_client",
"client_avatar": "target_client",
"industries": "industry",
"verticals": "industry",
"services": "service",
"service_stack": "service",
"offerings": "service",
"deliverables": "deliverable",
"skills": "skill",
"competencies": "skill",
"tools": "tool",
"stack": "tool",
"platforms": "tool",
"work_history": "work_history",
"experience": "experience",
"years_experience": "experience",
"projects": "project",
"portfolio": "portfolio",
"case_studies": "portfolio",
"testimonials": "testimonial",
"reviews": "testimonial",
"certifications": "certification",
"credentials": "certification",
"education": "education",
"pricing": "pricing",
"rates": "pricing",
"availability": "availability",
"proposal_preferences": "proposal_preference",
"preferred_project_types": "proposal_preference",
"strengths": "achievement",
"weaknesses": "weakness_or_constraint",
"constraints": "weakness_or_constraint",
}
def _stringify(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value
if isinstance(value, (list, tuple)):
return ", ".join(_stringify(v) for v in value if v not in (None, ""))
if isinstance(value, dict):
return ", ".join(f"{k}: {_stringify(v)}" for k, v in value.items())
return str(value)
def _scan_json_chunk(chunk: ChunkRecord) -> list[ProofPoint]:
payload = chunk.json_data
if not isinstance(payload, dict):
return []
proofs: list[ProofPoint] = []
for key, value in payload.items():
if value in (None, "", [], {}):
continue
if not isinstance(key, str):
continue
claim_type = _JSON_CLAIM_MAP.get(key.lower())
if claim_type is None:
claim_type = "other_relevant_evidence"
claim_text = _stringify(value)
if not claim_text:
continue
skills: list[str] = []
tools: list[str] = []
industries: list[str] = []
if claim_type == "skill" and isinstance(value, (list, tuple)):
skills = [str(v) for v in value if v]
if claim_type == "tool" and isinstance(value, (list, tuple)):
tools = [str(v) for v in value if v]
if claim_type == "industry" and isinstance(value, (list, tuple)):
industries = [str(v) for v in value if v]
proofs.append(
_make_proof(
chunk=chunk,
claim_type=claim_type,
claim_text=claim_text,
location=f"json::{key}",
normalized_value=claim_text if isinstance(value, str) else None,
skills=skills,
tools=tools,
industries=industries,
)
)
return proofs
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def build(chunks: Iterable[ChunkRecord]) -> list[ProofPoint]:
"""Build the evidence index from extracted dossier chunks.
Every readable chunk contributes proof points regardless of its
source type. Unknown-type files still surface any keyword-detectable
signals; if nothing matches, a low-confidence
``other_relevant_evidence`` placeholder is emitted so the file is
not silently dropped from the canonical profile's source summary.
"""
index: list[ProofPoint] = []
for chunk in chunks:
if chunk.extraction_status in {"failed", "metadata_only", "empty"}:
continue
if chunk.file_type == ".json" and chunk.json_data is not None:
index.extend(_scan_json_chunk(chunk))
else:
index.extend(_scan_text_chunk(chunk))
_resolve_conflicts(index)
return index
def _resolve_conflicts(proofs: list[ProofPoint]) -> None:
"""Mark superseded / conflicting / supporting proof points.
For each claim type we look at the highest-priority source. Lower
priority proofs of the same claim type are marked ``supporting`` by
default, or ``superseded`` if their normalized value disagrees with
the winner's normalized value.
"""
grouped: dict[ClaimType, list[ProofPoint]] = {}
for p in proofs:
grouped.setdefault(p.claim_type, []).append(p)
for claim_type, items in grouped.items():
items.sort(key=lambda p: p.source_priority)
if not items:
continue
winner = items[0]
for other in items[1:]:
if other.source_file == winner.source_file:
continue
if (
winner.normalized_value
and other.normalized_value
and winner.normalized_value.strip().lower()
!= other.normalized_value.strip().lower()
):
other.conflict_status = "superseded"
else:
other.conflict_status = "supporting"
# ---------------------------------------------------------------------------
# Canonical profile synthesis
# ---------------------------------------------------------------------------
_CLAIM_TO_FIELD: dict[ClaimType, str] = {
"identity": "name",
"positioning": "title_or_positioning",
"selected_offer": "selected_offer",
"target_client": "target_client",
"service": "services",
"deliverable": "deliverables",
"skill": "skills",
"tool": "tools",
"industry": "industries",
"project": "portfolio_or_proof",
"experience": "work_history",
"work_history": "work_history",
"metric": "achievements",
"testimonial": "portfolio_or_proof",
"certification": "certifications",
"education": "education",
"language": "languages",
"pricing": "pricing",
"availability": "preferred_project_types",
"proposal_preference": "proposal_preferences",
"weakness_or_constraint": "weaknesses_to_account_for",
"portfolio": "portfolio_or_proof",
"achievement": "achievements",
"location": "location",
"timezone": "timezone",
"guarantee": "guarantee",
"other_relevant_evidence": "", # routed to source_summary only
}
_LIST_FIELDS = {
"languages",
"industries",
"services",
"deliverables",
"skills",
"tools",
"work_history",
"education",
"certifications",
"portfolio_or_proof",
"achievements",
"preferred_project_types",
"proposal_preferences",
"strengths",
"weaknesses_to_account_for",
}
def _highest_confidence(proofs: list[ProofPoint]) -> ExtractionConfidence:
rank = {"high": 3, "medium": 2, "low": 1}
best = "low"
for p in proofs:
if rank[p.confidence] > rank[best]:
best = p.confidence
return best # type: ignore[return-value]
def _build_field(proofs: list[ProofPoint], field_name: str) -> CanonicalProfileField:
if not proofs:
return CanonicalProfileField()
proofs_sorted = sorted(proofs, key=lambda p: p.source_priority)
primary = proofs_sorted[0]
if field_name in _LIST_FIELDS:
seen: list[str] = []
for p in proofs_sorted:
for item in (p.skills or p.tools or p.industries or []):
if item and item not in seen:
seen.append(item)
if not (p.skills or p.tools or p.industries):
text = (p.normalized_value or p.claim_text).strip()
if text and text not in seen:
seen.append(text)
value: Any = seen if seen else primary.claim_text
else:
value = primary.normalized_value or primary.claim_text
conflict_note: Optional[str] = None
for other in proofs_sorted[1:]:
if other.conflict_status == "superseded":
conflict_note = (
f"Lower-priority source {other.source_file} "
f"disagreed and was marked superseded."
)
break
return CanonicalProfileField(
value=value,
evidence_ids=[p.evidence_id for p in proofs_sorted],
source_confidence=_highest_confidence(proofs_sorted),
conflict_note=conflict_note,
)
def synthesize_profile(
proofs: Iterable[ProofPoint],
) -> CanonicalFreelancerProfile:
"""Build a canonical freelancer profile from the evidence index."""
proof_list = list(proofs)
by_field: dict[str, list[ProofPoint]] = {}
for proof in proof_list:
field_name = _CLAIM_TO_FIELD.get(proof.claim_type, "")
if not field_name:
continue
by_field.setdefault(field_name, []).append(proof)
profile = CanonicalFreelancerProfile()
for field_name in (
"name",
"title_or_positioning",
"location",
"timezone",
"languages",
"selected_offer",
"guarantee",
"target_client",
"industries",
"services",
"deliverables",
"skills",
"tools",
"work_history",
"education",
"certifications",
"portfolio_or_proof",
"achievements",
"pricing",
"preferred_project_types",
"proposal_preferences",
"strengths",
"weaknesses_to_account_for",
):
setattr(profile, field_name, _build_field(by_field.get(field_name, []), field_name))
# Strengths: derived from metrics + achievements claim types if not set.
strengths_proofs = by_field.get("achievements", [])
profile.strengths = _build_field(strengths_proofs, "strengths") if strengths_proofs else CanonicalProfileField()
# Missing information: list canonical fields with no evidence.
missing = [
name
for name in (
"name",
"title_or_positioning",
"selected_offer",
"target_client",
"services",
"skills",
"tools",
"pricing",
"portfolio_or_proof",
"testimonials",
)
if not by_field.get(name) and name != "testimonials"
]
profile.missing_information = CanonicalProfileField(
value=missing,
source_confidence="high" if missing else "low",
)
# Source summary: file counts per source type.
summary: dict[str, int] = {}
for proof in proof_list:
summary[proof.source_type] = summary.get(proof.source_type, 0) + 1
profile.source_summary = CanonicalProfileField(
value=summary,
source_confidence="high" if summary else "low",
)
return profile
def build_profile(chunks: Iterable[ChunkRecord]) -> tuple[list[ProofPoint], CanonicalFreelancerProfile]:
"""Convenience wrapper: build the evidence index and canonical profile.
This is the deterministic local path. It always uses the keyword /
JSON scanner — it does not call any LLM. Use
:func:`build_evidence_index` if you want the LLM-preferred path that
records API usage in the session log.
"""
proofs = build(chunks)
profile = synthesize_profile(proofs)
return proofs, profile
TASK_NAME = "evidence_index_generation"
def build_evidence_index(
chunks: Iterable[ChunkRecord],
*,
allow_llm: bool = True,
) -> tuple[list[ProofPoint], CanonicalFreelancerProfile, dict]:
"""Build the evidence index, preferring LLM extraction when available.
The LLM path is currently not implemented (a structured-evidence LLM
prompt is a follow-up). Until that lands, this function always falls
back to the local keyword scanner and records the stage as
``LOCAL PLACEHOLDER`` in the session usage log so the API Usage
panel can flag it. The returned metadata dict carries
``used_api: False`` and a human-readable reason.
"""
from app.services import llm_client # local import to avoid cycles
chunk_list = list(chunks or [])
# NOTE: LLM-backed extraction will be wired up here. For now we keep
# the deterministic local path so the rest of the flow stays usable,
# but we record the call as a local placeholder.
proofs = build(chunk_list)
profile = synthesize_profile(proofs)
reason = (
"LLM-backed evidence extraction not yet implemented; "
"falling back to local keyword + JSON scanner."
)
llm_client.record_local_use(TASK_NAME, note=reason)
meta = {
"task_name": TASK_NAME,
"used_api": False,
"status": "local_placeholder",
"provider": None,
"model": None,
"error_message": reason,
"allow_llm": allow_llm,
}
return proofs, profile, meta