Initial commit: CI Agent competitive-intelligence monitoring app
FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
"""Task F: alert summarization. Produces the concise, non-exaggerated text
|
||||
shown in the alert/email/SMS (Phase 8) - short by design, honest about
|
||||
confidence, no hype language.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.analysis.llm.base import LLMProvider
|
||||
from app.prompts.base import build_user_prompt
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You write concise, factual alert summaries for a competitive intelligence tool. Never "
|
||||
"exaggerate. State what changed, cite the evidence type, and note the confidence level "
|
||||
"plainly. The title must be under 100 characters and must not use hype words like "
|
||||
"'huge', 'massive', or 'game-changing'."
|
||||
)
|
||||
|
||||
|
||||
class AlertSummary(BaseModel):
|
||||
title: str = Field(max_length=100)
|
||||
summary: str
|
||||
why_it_matters: str
|
||||
|
||||
|
||||
async def summarize_alert(
|
||||
llm: LLMProvider,
|
||||
*,
|
||||
company_name: str,
|
||||
change_type: str,
|
||||
change_summary: str,
|
||||
severity: str,
|
||||
confidence: float,
|
||||
evidence_snippets: list[str],
|
||||
) -> AlertSummary:
|
||||
evidence = {
|
||||
"company_name": company_name,
|
||||
"change_type": change_type,
|
||||
"change_summary": change_summary,
|
||||
"severity": severity,
|
||||
"confidence": confidence,
|
||||
"evidence_snippets": evidence_snippets[:10],
|
||||
}
|
||||
user_prompt = build_user_prompt("Write a concise alert summary for this change.", evidence)
|
||||
return await llm.generate_structured(SYSTEM_PROMPT, user_prompt, AlertSummary)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Shared helpers for building prompts and (for MockLLMProvider) recovering
|
||||
the structured evidence a prompt was built from, without a real model call.
|
||||
Every analysis task embeds its evidence as a fenced JSON block via
|
||||
`build_user_prompt`, so this stays consistent across all six tasks and lets
|
||||
the mock provider parse it back out deterministically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
_EVIDENCE_FENCE_START = "```json evidence"
|
||||
_EVIDENCE_FENCE_END = "```"
|
||||
|
||||
|
||||
def build_user_prompt(instructions: str, evidence: dict[str, Any]) -> str:
|
||||
evidence_json = json.dumps(evidence, indent=2, default=str)
|
||||
return (
|
||||
f"{instructions}\n\n"
|
||||
"Evidence (only use what is provided here - never invent facts not present):\n"
|
||||
f"{_EVIDENCE_FENCE_START}\n{evidence_json}\n{_EVIDENCE_FENCE_END}"
|
||||
)
|
||||
|
||||
|
||||
def extract_evidence_block(user_prompt: str) -> dict[str, Any]:
|
||||
"""Recovers the evidence dict embedded by `build_user_prompt`. Used only
|
||||
by MockLLMProvider, which has no model to actually read the prompt."""
|
||||
start = user_prompt.find(_EVIDENCE_FENCE_START)
|
||||
if start == -1:
|
||||
return {}
|
||||
start += len(_EVIDENCE_FENCE_START)
|
||||
end = user_prompt.find(_EVIDENCE_FENCE_END, start)
|
||||
if end == -1:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(user_prompt[start:end].strip())
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Task E: change significance narrative.
|
||||
|
||||
Severity itself stays deterministic (app/change_detection/scoring.py) - see
|
||||
ARCHITECTURE.md: "the LLM narrates why a change matters; it does not decide
|
||||
how much it matters." This task supplies the narrative explanation and a
|
||||
second, independent opinion on whether the change looks real/meaningful and
|
||||
notification-worthy, which the notification layer (Phase 8) can use as an
|
||||
extra signal alongside the deterministic severity - it never overrides it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.analysis.llm.base import LLMProvider
|
||||
from app.prompts.base import build_user_prompt
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a competitive intelligence analyst reviewing one detected change between two "
|
||||
"snapshots of a company's public information. Explain in plain language why this change "
|
||||
"would or wouldn't matter to someone monitoring this company, given their stated focus. "
|
||||
"Be honest about uncertainty - do not overstate a routine wording tweak as significant, "
|
||||
"and do not undersell a genuine signal."
|
||||
)
|
||||
|
||||
|
||||
class ChangeSignificanceAssessment(BaseModel):
|
||||
is_real_change: bool = Field(description="Does this look like a genuine change, not noise?")
|
||||
is_meaningful: bool
|
||||
why_it_matters: str
|
||||
confidence: float = Field(ge=0.0, le=1.0)
|
||||
should_notify: bool
|
||||
|
||||
|
||||
async def assess_change_significance(
|
||||
llm: LLMProvider,
|
||||
*,
|
||||
company_name: str,
|
||||
monitoring_focus: str | None,
|
||||
change_type: str,
|
||||
change_summary: str,
|
||||
added_text: list[str],
|
||||
removed_text: list[str],
|
||||
deterministic_severity: str,
|
||||
deterministic_confidence: float,
|
||||
) -> ChangeSignificanceAssessment:
|
||||
evidence = {
|
||||
"company_name": company_name,
|
||||
"monitoring_focus": monitoring_focus,
|
||||
"change_type": change_type,
|
||||
"change_summary": change_summary,
|
||||
"text_added": added_text[:20],
|
||||
"text_removed": removed_text[:20],
|
||||
"deterministic_severity": deterministic_severity,
|
||||
"deterministic_confidence": deterministic_confidence,
|
||||
}
|
||||
user_prompt = build_user_prompt(
|
||||
"Assess this detected change and explain why it does or doesn't matter.", evidence
|
||||
)
|
||||
return await llm.generate_structured(SYSTEM_PROMPT, user_prompt, ChangeSignificanceAssessment)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Company-profile discovery extraction. Used once per company, at
|
||||
onboarding time (see app/services/discovery_service.py) - never asked
|
||||
"tell me everything about this company," only "given this fetched
|
||||
homepage text and these search snippets, extract what's actually
|
||||
supported." Leaves a field unset rather than guessing when the evidence
|
||||
doesn't support it; the wizard's Review step shows unset fields as
|
||||
"not found" for the user to fill in themselves.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.analysis.llm.base import LLMProvider
|
||||
from app.prompts.base import build_user_prompt
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a competitive intelligence analyst building an initial company profile from "
|
||||
"search results and a fetched homepage. Extract only what the evidence actually states or "
|
||||
"clearly implies - never use outside knowledge, never guess. Leave a field null (or an "
|
||||
"empty list) rather than filling it with a plausible-sounding guess. Aliases means other "
|
||||
"names the company is or was known by (former names, common abbreviations, brand names) - "
|
||||
"not synonyms or descriptions. Competitors means other named companies the evidence "
|
||||
"explicitly identifies as competing in the same space."
|
||||
)
|
||||
|
||||
|
||||
class PublicIdentifier(BaseModel):
|
||||
key: str = Field(description="e.g. 'ticker', 'linkedin_url', 'cik'")
|
||||
value: str
|
||||
|
||||
|
||||
class CompanyProfileExtraction(BaseModel):
|
||||
description: str | None = Field(
|
||||
default=None,
|
||||
description="A short (1-3 sentence) factual summary of what the company does, drawn "
|
||||
"strictly from the evidence - not a marketing tagline.",
|
||||
)
|
||||
industry: str | None = None
|
||||
country: str | None = None
|
||||
region: str | None = None
|
||||
headquarters: str | None = None
|
||||
aliases: list[str] = Field(default_factory=list)
|
||||
competitors: list[str] = Field(default_factory=list)
|
||||
public_identifiers: list[PublicIdentifier] = Field(
|
||||
default_factory=list,
|
||||
description="Best-effort key/value pairs, e.g. ticker or linkedin_url - empty if none found. "
|
||||
"A list of {key, value} pairs rather than a free-form object, since the Gemini Developer "
|
||||
"API's structured-output mode rejects open-ended (additionalProperties) JSON schemas.",
|
||||
)
|
||||
|
||||
|
||||
async def extract_company_profile(
|
||||
llm: LLMProvider,
|
||||
*,
|
||||
company_name: str,
|
||||
homepage_url: str | None,
|
||||
homepage_text: str | None,
|
||||
search_results: list[dict],
|
||||
) -> CompanyProfileExtraction:
|
||||
evidence = {
|
||||
"company_name": company_name,
|
||||
"homepage_url": homepage_url,
|
||||
"homepage_text": (homepage_text or "")[:4000],
|
||||
"search_results": search_results[:15],
|
||||
}
|
||||
user_prompt = build_user_prompt(
|
||||
"Build an initial company profile (description, industry, country, region, "
|
||||
"headquarters, aliases, competitors, public identifiers) strictly from this evidence.",
|
||||
evidence,
|
||||
)
|
||||
return await llm.generate_structured(SYSTEM_PROMPT, user_prompt, CompanyProfileExtraction)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Task B: fact and signal extraction. Pulls discrete, evidence-anchored
|
||||
signals (hiring, partnerships, leadership, financial, etc.) out of a single
|
||||
document - the atomic units Task C (synthesis) and Task D (report
|
||||
generation) later combine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.analysis.llm.base import LLMProvider
|
||||
from app.prompts.base import build_user_prompt
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a competitive intelligence analyst extracting discrete factual signals from a "
|
||||
"single document. Extract only what the text actually states or clearly implies - never "
|
||||
"add outside knowledge. Every signal must include the exact passage that supports it."
|
||||
)
|
||||
|
||||
_SIGNAL_TYPES = (
|
||||
"event, entity, date, location, product, leadership, partnership, hiring, investment, "
|
||||
"manufacturing, technology, financial, regulatory, sentiment"
|
||||
)
|
||||
|
||||
|
||||
class ExtractedSignal(BaseModel):
|
||||
signal_type: str = Field(description=f"One of: {_SIGNAL_TYPES}")
|
||||
description: str
|
||||
supporting_passage: str = Field(description="The exact quote from the document backing this")
|
||||
date: str | None = None
|
||||
entities: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ExtractionResult(BaseModel):
|
||||
signals: list[ExtractedSignal] = Field(default_factory=list)
|
||||
|
||||
|
||||
async def extract_signals(
|
||||
llm: LLMProvider,
|
||||
*,
|
||||
document_title: str | None,
|
||||
document_url: str,
|
||||
document_text: str,
|
||||
) -> ExtractionResult:
|
||||
evidence = {
|
||||
"document_title": document_title,
|
||||
"document_url": document_url,
|
||||
"document_text": document_text[:6000],
|
||||
}
|
||||
user_prompt = build_user_prompt(
|
||||
"Extract every discrete factual signal from this document, each anchored to its "
|
||||
"exact supporting passage.",
|
||||
evidence,
|
||||
)
|
||||
return await llm.generate_structured(SYSTEM_PROMPT, user_prompt, ExtractionResult)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Task A: document relevance. Decides whether a collected document is
|
||||
actually about the target company and matches the user's stated focus,
|
||||
before it's used as evidence for anything else."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.analysis.llm.base import LLMProvider
|
||||
from app.prompts.base import build_user_prompt
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a competitive intelligence analyst. Given a single document and a company "
|
||||
"profile, judge only whether the document is genuinely about that company and whether "
|
||||
"it relates to the user's stated monitoring focus. Do not summarize the document's "
|
||||
"content here - a later task does that. Be conservative: if the document could be about "
|
||||
"a different company with a similar name, say so."
|
||||
)
|
||||
|
||||
|
||||
class RelevanceAssessment(BaseModel):
|
||||
is_relevant: bool = Field(description="Is this document genuinely about the target company?")
|
||||
matches_focus: bool = Field(description="Does it relate to the user's stated monitoring focus?")
|
||||
topic_categories: list[str] = Field(default_factory=list)
|
||||
source_reliability: float = Field(ge=0.0, le=1.0)
|
||||
reasoning: str
|
||||
|
||||
|
||||
async def assess_relevance(
|
||||
llm: LLMProvider,
|
||||
*,
|
||||
company_name: str,
|
||||
company_aliases: list[str],
|
||||
monitoring_focus: str | None,
|
||||
document_title: str | None,
|
||||
document_text: str,
|
||||
document_url: str,
|
||||
) -> RelevanceAssessment:
|
||||
evidence = {
|
||||
"company_name": company_name,
|
||||
"company_aliases": company_aliases,
|
||||
"monitoring_focus": monitoring_focus,
|
||||
"document_title": document_title,
|
||||
"document_url": document_url,
|
||||
"document_text": document_text[:4000],
|
||||
}
|
||||
user_prompt = build_user_prompt(
|
||||
"Assess whether this document is about the target company and matches its monitoring focus.",
|
||||
evidence,
|
||||
)
|
||||
return await llm.generate_structured(SYSTEM_PROMPT, user_prompt, RelevanceAssessment)
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Task D: report generation. Turns accumulated evidence (source documents +
|
||||
detected changes) for a company into the structured CI report (spec section
|
||||
17). The LLM only ever sees evidence this pipeline collected - it is
|
||||
explicitly instructed not to introduce outside knowledge, and every
|
||||
non-trivial claim must carry a confidence label and evidence references.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.analysis.llm.base import LLMProvider
|
||||
from app.prompts.base import build_user_prompt
|
||||
from app.prompts.schemas import ConfidenceLabel, EvidenceRef, Finding
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a competitive intelligence analyst producing a structured report about a "
|
||||
"company, using ONLY the evidence provided - the company_profile block (discovered once "
|
||||
"at onboarding from the company's real website and search results, not your own "
|
||||
"knowledge), the company_enrichment block (when present - fetched once from a paid "
|
||||
"third-party data provider at onboarding, also real evidence, not your own knowledge), "
|
||||
"stored source documents, and previously detected changes. Never introduce "
|
||||
"facts from outside knowledge, even if you recognize the company - if it isn't in the "
|
||||
"evidence block, it doesn't go in the report. The company_profile and company_enrichment "
|
||||
"fields ARE real evidence and should ground company_overview/market_positioning/"
|
||||
"financial_signals/leadership_changes/products_and_services/competitor_comparison/"
|
||||
"customer_sentiment/etc even when source_documents and detected_changes are sparse or "
|
||||
"empty - do not say 'insufficient evidence' for a field company_profile or "
|
||||
"company_enrichment already answers. Every finding must be "
|
||||
"traceable to the evidence given and must carry an honest confidence label: confirmed "
|
||||
"(the source states it directly), strongly_indicated, likely, possible, unconfirmed, or "
|
||||
"insufficient_evidence. When evidence is thin or missing for a section, say so explicitly "
|
||||
"in that section rather than inventing content. Distinguish clearly between what a source "
|
||||
"states and what you are inferring."
|
||||
)
|
||||
|
||||
|
||||
class InferredProject(BaseModel):
|
||||
project_name: str
|
||||
status: ConfidenceLabel
|
||||
summary: str
|
||||
confidence: float = Field(ge=0.0, le=1.0)
|
||||
evidence: list[EvidenceRef] = Field(default_factory=list)
|
||||
signal_types: list[str] = Field(default_factory=list)
|
||||
alternative_explanations: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SwotAnalysis(BaseModel):
|
||||
strengths: list[str] = Field(default_factory=list)
|
||||
weaknesses: list[str] = Field(default_factory=list)
|
||||
opportunities: list[str] = Field(default_factory=list)
|
||||
threats: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ReportContent(BaseModel):
|
||||
executive_summary: str
|
||||
company_overview: str
|
||||
products_and_services: list[Finding] = Field(default_factory=list)
|
||||
market_positioning: str
|
||||
recent_developments: list[Finding] = Field(default_factory=list)
|
||||
strategic_initiatives: list[Finding] = Field(default_factory=list)
|
||||
key_inferred_projects: list[InferredProject] = Field(default_factory=list)
|
||||
leadership_changes: list[Finding] = Field(default_factory=list)
|
||||
hiring_signals: list[Finding] = Field(default_factory=list)
|
||||
technology_signals: list[Finding] = Field(default_factory=list)
|
||||
patent_signals: list[Finding] = Field(default_factory=list)
|
||||
manufacturing_and_expansion_signals: list[Finding] = Field(default_factory=list)
|
||||
partnerships_and_acquisitions: list[Finding] = Field(default_factory=list)
|
||||
financial_signals: list[Finding] = Field(default_factory=list)
|
||||
regulatory_and_legal_signals: list[Finding] = Field(default_factory=list)
|
||||
customer_sentiment: str
|
||||
competitor_comparison: str
|
||||
swot: SwotAnalysis
|
||||
risks: list[str] = Field(default_factory=list)
|
||||
opportunities: list[str] = Field(default_factory=list)
|
||||
unknowns_and_missing_data: list[str] = Field(default_factory=list)
|
||||
monitoring_recommendations: list[str] = Field(default_factory=list)
|
||||
methodology: str
|
||||
limitations: str
|
||||
|
||||
|
||||
async def generate_report(
|
||||
llm: LLMProvider,
|
||||
*,
|
||||
company_name: str,
|
||||
company_aliases: list[str],
|
||||
competitors: list[str],
|
||||
monitoring_focus: str | None,
|
||||
industry: str | None,
|
||||
documents: list[dict],
|
||||
detected_changes: list[dict],
|
||||
sources_failed: list[str],
|
||||
description: str | None = None,
|
||||
official_website: str | None = None,
|
||||
headquarters: str | None = None,
|
||||
country: str | None = None,
|
||||
region: str | None = None,
|
||||
public_identifiers: dict[str, str] | None = None,
|
||||
enrichment: dict | None = None,
|
||||
) -> ReportContent:
|
||||
evidence = {
|
||||
# Discovered once at onboarding (app/services/discovery_service.py)
|
||||
# from the company's real website + search results - genuine
|
||||
# evidence, not the LLM's own background knowledge, and the only
|
||||
# evidence available before any monitoring run has collected
|
||||
# source_documents/detected_changes.
|
||||
"company_profile": {
|
||||
"name": company_name,
|
||||
"description": description,
|
||||
"official_website": official_website,
|
||||
"aliases": company_aliases,
|
||||
"competitors": competitors,
|
||||
"monitoring_focus": monitoring_focus,
|
||||
"industry": industry,
|
||||
"headquarters": headquarters,
|
||||
"country": country,
|
||||
"region": region,
|
||||
"public_identifiers": public_identifiers or {},
|
||||
},
|
||||
# Fetched once, at onboarding, from a paid third-party provider
|
||||
# (NinjaPear) - see app/services/enrichment_service.py. Absent for
|
||||
# any company created before this feature existed, or whose
|
||||
# enrichment never completed successfully - never fabricated.
|
||||
"company_enrichment": enrichment or {},
|
||||
"source_documents": documents,
|
||||
"detected_changes": detected_changes,
|
||||
"sources_that_failed_to_collect": sources_failed,
|
||||
}
|
||||
user_prompt = build_user_prompt(
|
||||
"Produce a full structured competitive intelligence report from this evidence. "
|
||||
"If a section has no supporting evidence, say so explicitly instead of leaving it "
|
||||
"generic or inventing content.",
|
||||
evidence,
|
||||
)
|
||||
return await llm.generate_structured(SYSTEM_PROMPT, user_prompt, ReportContent)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Shared building blocks for analysis-task response schemas. Every finding
|
||||
that claims something happened carries an explicit confidence label from
|
||||
this set - never presented as bare fact (spec section 16 / "Evidence and
|
||||
Anti-Hallucination Requirements")."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ConfidenceLabel(StrEnum):
|
||||
CONFIRMED = "confirmed"
|
||||
STRONGLY_INDICATED = "strongly_indicated"
|
||||
LIKELY = "likely"
|
||||
POSSIBLE = "possible"
|
||||
UNCONFIRMED = "unconfirmed"
|
||||
INSUFFICIENT_EVIDENCE = "insufficient_evidence"
|
||||
|
||||
|
||||
class EvidenceRef(BaseModel):
|
||||
source_document_id: str | None = None
|
||||
detected_change_id: str | None = None
|
||||
url: str | None = None
|
||||
description: str = Field(description="What this piece of evidence shows, in one sentence")
|
||||
|
||||
|
||||
class Finding(BaseModel):
|
||||
headline: str
|
||||
summary: str
|
||||
evidence: list[EvidenceRef] = Field(default_factory=list)
|
||||
confidence: ConfidenceLabel = ConfidenceLabel.UNCONFIRMED
|
||||
category: str | None = None
|
||||
date: str | None = None
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Task C: cross-source synthesis. Combines related signals from multiple
|
||||
documents/changes into a conclusion (e.g. "hiring battery engineers" +
|
||||
"filed a battery patent" + "announced a facility expansion" = "possible
|
||||
battery manufacturing initiative"), always with alternative explanations and
|
||||
an explicit accounting of what's still unknown.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.analysis.llm.base import LLMProvider
|
||||
from app.prompts.base import build_user_prompt
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a competitive intelligence analyst. Given a set of independently-extracted "
|
||||
"signals about a company, identify conclusions that multiple signals point toward "
|
||||
"together. Only draw a conclusion when the evidence actually supports it - state your "
|
||||
"confidence honestly, list plausible alternative explanations, and note what evidence "
|
||||
"would be needed to be more certain. Do not synthesize a conclusion from a single signal."
|
||||
)
|
||||
|
||||
|
||||
class SynthesizedConclusion(BaseModel):
|
||||
conclusion: str
|
||||
evidence_summary: list[str] = Field(default_factory=list)
|
||||
source_count: int = Field(ge=0)
|
||||
confidence: float = Field(ge=0.0, le=1.0)
|
||||
alternative_explanations: list[str] = Field(default_factory=list)
|
||||
missing_information: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SynthesisResult(BaseModel):
|
||||
conclusions: list[SynthesizedConclusion] = Field(default_factory=list)
|
||||
|
||||
|
||||
async def synthesize_signals(
|
||||
llm: LLMProvider,
|
||||
*,
|
||||
company_name: str,
|
||||
monitoring_focus: str | None,
|
||||
signals: list[dict],
|
||||
) -> SynthesisResult:
|
||||
evidence = {
|
||||
"company_name": company_name,
|
||||
"monitoring_focus": monitoring_focus,
|
||||
"signals": signals,
|
||||
}
|
||||
user_prompt = build_user_prompt(
|
||||
"Identify conclusions supported by two or more of these signals together. Do not "
|
||||
"synthesize from a single signal alone.",
|
||||
evidence,
|
||||
)
|
||||
return await llm.generate_structured(SYSTEM_PROMPT, user_prompt, SynthesisResult)
|
||||
Reference in New Issue
Block a user