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,82 @@
|
||||
"""Anthropic provider: structured output via forced tool-use (the response
|
||||
schema becomes the tool's input_schema, so the model can only "call" it with
|
||||
arguments matching the shape we asked for), with a bounded repair loop for
|
||||
the rare malformed response.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from anthropic import AsyncAnthropic
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from app.analysis.llm.base import LLMResponseError
|
||||
from app.core.config import Settings
|
||||
|
||||
_TOOL_NAME = "emit_result"
|
||||
|
||||
|
||||
class AnthropicLLMProvider:
|
||||
provider_name = "anthropic"
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._settings = settings
|
||||
self._client = AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
|
||||
async def generate_structured[T: BaseModel](
|
||||
self, system_prompt: str, user_prompt: str, response_model: type[T]
|
||||
) -> T:
|
||||
tools = [
|
||||
{
|
||||
"name": _TOOL_NAME,
|
||||
"description": f"Emit the result matching the {response_model.__name__} schema.",
|
||||
"input_schema": response_model.model_json_schema(),
|
||||
}
|
||||
]
|
||||
|
||||
last_error: Exception | None = None
|
||||
messages: list[dict] = [{"role": "user", "content": user_prompt}]
|
||||
|
||||
for attempt in range(self._settings.llm_max_retries + 1):
|
||||
if attempt > 0 and last_error is not None:
|
||||
messages = [
|
||||
*messages,
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"Your previous response did not match the required schema: "
|
||||
f"{last_error}. Please try again."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
response = await self._client.messages.create(
|
||||
model=self._settings.anthropic_model,
|
||||
max_tokens=self._settings.llm_max_tokens_per_request,
|
||||
system=system_prompt,
|
||||
tools=tools,
|
||||
tool_choice={"type": "tool", "name": _TOOL_NAME},
|
||||
messages=messages,
|
||||
)
|
||||
tool_use = next((b for b in response.content if b.type == "tool_use"), None)
|
||||
if tool_use is None:
|
||||
last_error = ValueError("No tool_use block in the model's response")
|
||||
continue
|
||||
try:
|
||||
return response_model.model_validate(tool_use.input)
|
||||
except ValidationError as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
|
||||
raise LLMResponseError(
|
||||
f"Anthropic provider failed to produce a valid {response_model.__name__} after "
|
||||
f"{self._settings.llm_max_retries + 1} attempt(s): {last_error}"
|
||||
)
|
||||
|
||||
async def generate_text(self, system_prompt: str, user_prompt: str) -> str:
|
||||
response = await self._client.messages.create(
|
||||
model=self._settings.anthropic_model,
|
||||
max_tokens=self._settings.llm_max_tokens_per_request,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
)
|
||||
return "\n".join(block.text for block in response.content if block.type == "text")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""LLM provider interface. Every analysis task (app/prompts/*.py) is written
|
||||
against this Protocol, never against a specific vendor SDK - swapping
|
||||
`LLM_PROVIDER` changes which class `get_llm_provider()` returns and nothing
|
||||
else has to change. `generate_structured` is the primary method: it always
|
||||
returns a validated instance of the caller's Pydantic response model, never
|
||||
raw text, so a malformed model response can never propagate un-typed data
|
||||
into the rest of the app (see `LLMResponseError` / the repair loop in
|
||||
anthropic_provider.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LLMResponseError(Exception):
|
||||
"""Raised when a provider can't produce a response matching the
|
||||
requested schema, even after any repair attempts."""
|
||||
|
||||
|
||||
class LLMProvider(Protocol):
|
||||
provider_name: str
|
||||
|
||||
async def generate_structured[T: BaseModel](
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
response_model: type[T],
|
||||
) -> T: ...
|
||||
|
||||
async def generate_text(self, system_prompt: str, user_prompt: str) -> str: ...
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Resolves `LLM_PROVIDER` to a concrete provider instance. Never imported
|
||||
directly by prompt task modules or services - always go through
|
||||
`get_llm_provider()` so swapping providers stays a one-line config change.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.analysis.llm.base import LLMProvider
|
||||
from app.analysis.llm.mock import MockLLMProvider
|
||||
from app.core.config import Settings, get_settings
|
||||
|
||||
|
||||
def get_llm_provider(settings: Settings | None = None) -> LLMProvider:
|
||||
settings = settings or get_settings()
|
||||
|
||||
if settings.llm_provider == "anthropic":
|
||||
from app.analysis.llm.anthropic_provider import AnthropicLLMProvider
|
||||
|
||||
return AnthropicLLMProvider(settings)
|
||||
|
||||
if settings.llm_provider == "ollama":
|
||||
from app.analysis.llm.ollama_provider import OllamaLLMProvider
|
||||
|
||||
return OllamaLLMProvider(settings)
|
||||
|
||||
if settings.llm_provider == "gemini":
|
||||
from app.analysis.llm.gemini_provider import GeminiLLMProvider
|
||||
|
||||
return GeminiLLMProvider(settings)
|
||||
|
||||
return MockLLMProvider()
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Gemini provider: structured output via the SDK's native
|
||||
`response_schema` support (the model is constrained to the schema and the
|
||||
SDK parses the result into an instance of it directly), with the same
|
||||
bounded repair loop on a malformed/unparsed response as
|
||||
`anthropic_provider.py`. Chosen as the production LLM_PROVIDER option
|
||||
alongside Anthropic because Gemini has an actual free rate-limited API
|
||||
tier (gemini-2.0-flash), unlike OpenAI's expiring trial credits.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from app.analysis.llm.base import LLMResponseError
|
||||
from app.core.config import Settings
|
||||
|
||||
|
||||
class GeminiLLMProvider:
|
||||
provider_name = "gemini"
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._settings = settings
|
||||
self._client = genai.Client(api_key=settings.gemini_api_key)
|
||||
|
||||
async def generate_structured[T: BaseModel](
|
||||
self, system_prompt: str, user_prompt: str, response_model: type[T]
|
||||
) -> T:
|
||||
last_error: Exception | None = None
|
||||
prompt = user_prompt
|
||||
|
||||
for attempt in range(self._settings.llm_max_retries + 1):
|
||||
if attempt > 0 and last_error is not None:
|
||||
prompt = (
|
||||
f"{user_prompt}\n\nYour previous response did not match the required "
|
||||
f"schema: {last_error}. Please try again."
|
||||
)
|
||||
|
||||
response = await self._client.aio.models.generate_content(
|
||||
model=self._settings.gemini_model,
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
system_instruction=system_prompt,
|
||||
response_mime_type="application/json",
|
||||
response_schema=response_model,
|
||||
max_output_tokens=self._settings.llm_max_tokens_per_request,
|
||||
),
|
||||
)
|
||||
if response.parsed is None:
|
||||
last_error = ValueError("Gemini did not return a parsed structured response")
|
||||
continue
|
||||
try:
|
||||
return response_model.model_validate(response.parsed)
|
||||
except ValidationError as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
|
||||
raise LLMResponseError(
|
||||
f"Gemini provider failed to produce a valid {response_model.__name__} after "
|
||||
f"{self._settings.llm_max_retries + 1} attempt(s): {last_error}"
|
||||
)
|
||||
|
||||
async def generate_text(self, system_prompt: str, user_prompt: str) -> str:
|
||||
response = await self._client.aio.models.generate_content(
|
||||
model=self._settings.gemini_model,
|
||||
contents=user_prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
system_instruction=system_prompt,
|
||||
max_output_tokens=self._settings.llm_max_tokens_per_request,
|
||||
),
|
||||
)
|
||||
return response.text or ""
|
||||
@@ -0,0 +1,327 @@
|
||||
"""Deterministic mock provider - the default (`LLM_PROVIDER=mock`) and what
|
||||
every automated test runs against. Never calls a network or costs money.
|
||||
|
||||
Rather than a generic reflection-based filler, each of the six analysis
|
||||
tasks gets a purpose-built, deterministic builder that reads the same
|
||||
evidence block a real model would see (see app/prompts/base.py) and
|
||||
produces genuinely useful output from it - real counts, real titles, real
|
||||
severities - never fabricated facts. This is what makes the fixture demo
|
||||
(Phase 9) work end-to-end without a paid API key.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.prompts.alert_summarization import AlertSummary
|
||||
from app.prompts.base import extract_evidence_block
|
||||
from app.prompts.change_significance import ChangeSignificanceAssessment
|
||||
from app.prompts.company_profile import CompanyProfileExtraction
|
||||
from app.prompts.extraction import ExtractionResult
|
||||
from app.prompts.relevance import RelevanceAssessment
|
||||
from app.prompts.report_generation import ReportContent
|
||||
from app.prompts.synthesis import SynthesisResult
|
||||
|
||||
|
||||
def _confidence_label(score: float) -> str:
|
||||
if score >= 0.85:
|
||||
return "confirmed"
|
||||
if score >= 0.65:
|
||||
return "strongly_indicated"
|
||||
if score >= 0.45:
|
||||
return "likely"
|
||||
if score >= 0.25:
|
||||
return "possible"
|
||||
if score > 0:
|
||||
return "unconfirmed"
|
||||
return "insufficient_evidence"
|
||||
|
||||
|
||||
def _build_relevance(evidence: dict[str, Any]) -> dict[str, Any]:
|
||||
text = (evidence.get("document_text") or "").lower()
|
||||
focus = (evidence.get("monitoring_focus") or "").lower()
|
||||
focus_words = [w for w in focus.split() if len(w) > 4]
|
||||
matches_focus = any(w in text for w in focus_words) if focus_words else False
|
||||
return {
|
||||
"is_relevant": True,
|
||||
"matches_focus": matches_focus,
|
||||
"topic_categories": [],
|
||||
"source_reliability": 0.7,
|
||||
"reasoning": "Mock provider: keyword-based heuristic (no live LLM configured).",
|
||||
}
|
||||
|
||||
|
||||
def _build_extraction(evidence: dict[str, Any]) -> dict[str, Any]:
|
||||
text = (evidence.get("document_text") or "").strip()
|
||||
if not text:
|
||||
return {"signals": []}
|
||||
first_sentence = text.split(".")[0][:200].strip()
|
||||
if not first_sentence:
|
||||
return {"signals": []}
|
||||
return {
|
||||
"signals": [
|
||||
{
|
||||
"signal_type": "event",
|
||||
"description": first_sentence,
|
||||
"supporting_passage": first_sentence,
|
||||
"date": None,
|
||||
"entities": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _build_synthesis(evidence: dict[str, Any]) -> dict[str, Any]:
|
||||
signals = evidence.get("signals") or []
|
||||
if len(signals) < 2:
|
||||
return {"conclusions": []}
|
||||
return {
|
||||
"conclusions": [
|
||||
{
|
||||
"conclusion": (
|
||||
"Multiple related signals were detected together; the mock provider "
|
||||
"does not attempt fine-grained synthesis - configure a live LLM provider "
|
||||
"for a specific conclusion."
|
||||
),
|
||||
"evidence_summary": [s.get("description", "") for s in signals[:5]],
|
||||
"source_count": len(signals),
|
||||
"confidence": 0.3,
|
||||
"alternative_explanations": [
|
||||
"A live LLM provider would assess this more precisely."
|
||||
],
|
||||
"missing_information": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _build_report(evidence: dict[str, Any]) -> dict[str, Any]:
|
||||
profile = evidence.get("company_profile") or {}
|
||||
company_name = profile.get("name") or "The company"
|
||||
documents = evidence.get("source_documents") or []
|
||||
changes = evidence.get("detected_changes") or []
|
||||
failed = evidence.get("sources_that_failed_to_collect") or []
|
||||
|
||||
# The discovered profile (real, from onboarding) is genuine evidence even
|
||||
# when no monitoring run has collected source_documents/detected_changes
|
||||
# yet - build company_overview/market_positioning from it honestly
|
||||
# rather than defaulting straight to "insufficient evidence".
|
||||
overview_parts = []
|
||||
if profile.get("description"):
|
||||
overview_parts.append(profile["description"])
|
||||
facts = []
|
||||
if profile.get("industry"):
|
||||
facts.append(f"industry: {profile['industry']}")
|
||||
if profile.get("headquarters"):
|
||||
facts.append(f"headquartered in {profile['headquarters']}")
|
||||
elif profile.get("country") or profile.get("region"):
|
||||
facts.append(
|
||||
f"based in {', '.join(f for f in (profile.get('country'), profile.get('region')) if f)}"
|
||||
)
|
||||
if profile.get("aliases"):
|
||||
facts.append(f"also known as {', '.join(profile['aliases'])}")
|
||||
if facts:
|
||||
overview_parts.append(f"{company_name} ({'; '.join(facts)}).")
|
||||
company_overview = " ".join(overview_parts) or f"No description on file for {company_name}."
|
||||
|
||||
if profile.get("competitors"):
|
||||
market_positioning = (
|
||||
f"{company_name} operates in a space that includes "
|
||||
f"{', '.join(profile['competitors'])} as named competitors, per the discovered "
|
||||
"company profile. No comparative data (pricing, features, market share) is "
|
||||
"available to assess relative positioning."
|
||||
)
|
||||
else:
|
||||
market_positioning = "Insufficient evidence to assess market positioning."
|
||||
|
||||
executive_summary = (
|
||||
f"Mock analysis (LLM_PROVIDER=mock) based on {len(documents)} collected document(s) "
|
||||
f"and {len(changes)} detected change(s) for {company_name}."
|
||||
)
|
||||
if failed:
|
||||
executive_summary += (
|
||||
f" {len(failed)} source(s) failed to collect this run and are excluded below."
|
||||
)
|
||||
|
||||
recent_developments = [
|
||||
{
|
||||
"headline": change.get("summary") or "Change detected",
|
||||
"summary": (
|
||||
f"{(change.get('change_type') or 'change').replace('_', ' ')} detected "
|
||||
f"with {change.get('severity') or 'unknown'} severity."
|
||||
),
|
||||
"evidence": [
|
||||
{
|
||||
"detected_change_id": change.get("id"),
|
||||
"description": change.get("summary") or "",
|
||||
}
|
||||
],
|
||||
"confidence": _confidence_label(change.get("confidence_score") or 0.5),
|
||||
"category": change.get("change_type"),
|
||||
"date": change.get("created_at"),
|
||||
}
|
||||
for change in changes[:10]
|
||||
]
|
||||
|
||||
hiring_signals = [
|
||||
{
|
||||
"headline": doc.get("title") or doc.get("url") or "Job posting",
|
||||
"summary": (doc.get("excerpt") or "")[:280],
|
||||
"evidence": [
|
||||
{
|
||||
"source_document_id": doc.get("id"),
|
||||
"url": doc.get("url"),
|
||||
"description": "Collected source document",
|
||||
}
|
||||
],
|
||||
"confidence": "confirmed",
|
||||
"category": "job_posting",
|
||||
"date": doc.get("retrieved_date"),
|
||||
}
|
||||
for doc in documents
|
||||
if doc.get("source_type") == "job_posting"
|
||||
]
|
||||
|
||||
unknowns = (
|
||||
[f"{len(failed)} source(s) failed to collect this run: {', '.join(failed[:5])}"]
|
||||
if failed
|
||||
else []
|
||||
)
|
||||
|
||||
return {
|
||||
"executive_summary": executive_summary,
|
||||
"company_overview": company_overview,
|
||||
"products_and_services": [],
|
||||
"market_positioning": market_positioning,
|
||||
"recent_developments": recent_developments,
|
||||
"strategic_initiatives": [],
|
||||
"key_inferred_projects": [],
|
||||
"leadership_changes": [
|
||||
d for d in recent_developments if d["category"] == "leadership_change"
|
||||
],
|
||||
"hiring_signals": hiring_signals,
|
||||
"technology_signals": [],
|
||||
"patent_signals": [],
|
||||
"manufacturing_and_expansion_signals": [],
|
||||
"partnerships_and_acquisitions": [],
|
||||
"financial_signals": [d for d in recent_developments if d["category"] == "filing_new"],
|
||||
"regulatory_and_legal_signals": [],
|
||||
"customer_sentiment": "Insufficient evidence to assess customer sentiment.",
|
||||
"competitor_comparison": "Insufficient evidence to compare against competitors.",
|
||||
"swot": {"strengths": [], "weaknesses": [], "opportunities": [], "threats": []},
|
||||
"risks": [],
|
||||
"opportunities": [],
|
||||
"unknowns_and_missing_data": unknowns,
|
||||
"monitoring_recommendations": ["Continue monitoring configured sources on schedule."],
|
||||
"methodology": (
|
||||
f"Generated by the mock LLM provider from {len(documents)} stored source "
|
||||
f"document(s) and {len(changes)} deterministic change-detection result(s). No "
|
||||
"external model was called."
|
||||
),
|
||||
"limitations": (
|
||||
"Generated by the deterministic mock provider, not a live LLM. Set "
|
||||
"LLM_PROVIDER=anthropic or LLM_PROVIDER=ollama for narrative synthesis."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _build_change_significance(evidence: dict[str, Any]) -> dict[str, Any]:
|
||||
severity = evidence.get("deterministic_severity") or "low"
|
||||
confidence = evidence.get("deterministic_confidence") or 0.5
|
||||
is_meaningful = severity in ("critical", "high", "medium")
|
||||
change_type = (evidence.get("change_type") or "change").replace("_", " ")
|
||||
return {
|
||||
"is_real_change": True,
|
||||
"is_meaningful": is_meaningful,
|
||||
"why_it_matters": (
|
||||
f"Deterministic scoring classified this {change_type} as {severity} severity "
|
||||
f"with {confidence:.0%} confidence."
|
||||
),
|
||||
"confidence": confidence,
|
||||
"should_notify": is_meaningful,
|
||||
}
|
||||
|
||||
|
||||
def _build_alert_summary(evidence: dict[str, Any]) -> dict[str, Any]:
|
||||
company = evidence.get("company_name") or "The company"
|
||||
change_type = (evidence.get("change_type") or "change").replace("_", " ")
|
||||
severity = evidence.get("severity") or "medium"
|
||||
confidence = evidence.get("confidence") or 0.5
|
||||
return {
|
||||
"title": f"{company}: {change_type} detected"[:100],
|
||||
"summary": evidence.get("change_summary") or f"A {change_type} was detected for {company}.",
|
||||
"why_it_matters": f"Classified as {severity} severity with {confidence:.0%} confidence.",
|
||||
}
|
||||
|
||||
|
||||
_HQ_RE = re.compile(r"(?:headquartered|based) in ([A-Z][\w\s,]{2,60}?)(?:[.\n]|$)", re.IGNORECASE)
|
||||
_FORMERLY_RE = re.compile(
|
||||
r"formerly (?:known as|named) ([A-Z][\w&\s]{2,60}?)(?:[.,\n]|$)", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def _build_company_profile(evidence: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Mirrors app/change_detection/extractors.py's philosophy: cheap,
|
||||
deterministic regex heuristics over real evidence text, never a
|
||||
fabricated guess. Most fields (industry/country/region/public
|
||||
identifiers) stay empty since a name-only mock search has no real
|
||||
signal for them - see search/mock.py's docstring for why that's
|
||||
intentional, not a gap."""
|
||||
homepage_text = evidence.get("homepage_text") or ""
|
||||
search_results = evidence.get("search_results") or []
|
||||
combined_text = homepage_text + "\n" + "\n".join(r.get("snippet", "") for r in search_results)
|
||||
|
||||
hq_match = _HQ_RE.search(combined_text)
|
||||
headquarters = hq_match.group(1).strip() if hq_match else None
|
||||
|
||||
alias_match = _FORMERLY_RE.search(combined_text)
|
||||
aliases = [alias_match.group(1).strip()] if alias_match else []
|
||||
|
||||
# First real sentence of the fetched homepage, if any - an honest,
|
||||
# evidence-derived summary rather than a fabricated one.
|
||||
first_sentence = re.split(r"(?<=[.!?])\s", homepage_text.strip(), maxsplit=1)[0].strip()
|
||||
description = first_sentence[:280] if first_sentence and len(first_sentence) > 15 else None
|
||||
|
||||
return {
|
||||
"description": description,
|
||||
"industry": None,
|
||||
"country": None,
|
||||
"region": None,
|
||||
"headquarters": headquarters,
|
||||
"aliases": aliases,
|
||||
"competitors": [],
|
||||
"public_identifiers": [],
|
||||
}
|
||||
|
||||
|
||||
_BUILDERS = {
|
||||
RelevanceAssessment: _build_relevance,
|
||||
ExtractionResult: _build_extraction,
|
||||
SynthesisResult: _build_synthesis,
|
||||
ReportContent: _build_report,
|
||||
ChangeSignificanceAssessment: _build_change_significance,
|
||||
AlertSummary: _build_alert_summary,
|
||||
CompanyProfileExtraction: _build_company_profile,
|
||||
}
|
||||
|
||||
|
||||
class MockLLMProvider:
|
||||
provider_name = "mock"
|
||||
|
||||
async def generate_structured[T: BaseModel](
|
||||
self, system_prompt: str, user_prompt: str, response_model: type[T]
|
||||
) -> T:
|
||||
evidence = extract_evidence_block(user_prompt)
|
||||
builder = _BUILDERS.get(response_model)
|
||||
data = builder(evidence) if builder is not None else {}
|
||||
return response_model.model_validate(data)
|
||||
|
||||
async def generate_text(self, system_prompt: str, user_prompt: str) -> str:
|
||||
evidence = extract_evidence_block(user_prompt)
|
||||
return (
|
||||
"[mock provider] No live LLM configured. "
|
||||
f"{len(evidence)} evidence field(s) were provided for this request."
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Ollama provider: local models via Ollama's HTTP API. Uses JSON mode
|
||||
(`format: "json"`) plus a bounded repair loop, since not every locally-run
|
||||
model supports strict schema-constrained decoding the way Anthropic's
|
||||
tool-use does - the schema is instead embedded in the system prompt as an
|
||||
instruction.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from app.analysis.llm.base import LLMResponseError
|
||||
from app.core.config import Settings
|
||||
|
||||
|
||||
class OllamaLLMProvider:
|
||||
provider_name = "ollama"
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._settings = settings
|
||||
|
||||
async def generate_structured[T: BaseModel](
|
||||
self, system_prompt: str, user_prompt: str, response_model: type[T]
|
||||
) -> T:
|
||||
schema_instructions = (
|
||||
f"{system_prompt}\n\nRespond with ONLY a single JSON object matching this JSON "
|
||||
f"schema, no other text, no markdown fences:\n"
|
||||
f"{json.dumps(response_model.model_json_schema())}"
|
||||
)
|
||||
|
||||
last_error: Exception | None = None
|
||||
prompt = user_prompt
|
||||
|
||||
for attempt in range(self._settings.llm_max_retries + 1):
|
||||
if attempt > 0 and last_error is not None:
|
||||
prompt = (
|
||||
f"{user_prompt}\n\nYour previous response was invalid: {last_error}. "
|
||||
"Try again, returning ONLY valid JSON matching the schema."
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
response = await client.post(
|
||||
f"{self._settings.ollama_base_url}/api/chat",
|
||||
json={
|
||||
"model": self._settings.ollama_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": schema_instructions},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"format": "json",
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
content = response.json().get("message", {}).get("content", "")
|
||||
try:
|
||||
data = json.loads(content)
|
||||
return response_model.model_validate(data)
|
||||
except (json.JSONDecodeError, ValidationError) as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
|
||||
raise LLMResponseError(
|
||||
f"Ollama provider failed to produce a valid {response_model.__name__} after "
|
||||
f"{self._settings.llm_max_retries + 1} attempt(s): {last_error}"
|
||||
)
|
||||
|
||||
async def generate_text(self, system_prompt: str, user_prompt: str) -> str:
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
response = await client.post(
|
||||
f"{self._settings.ollama_base_url}/api/chat",
|
||||
json={
|
||||
"model": self._settings.ollama_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json().get("message", {}).get("content", "")
|
||||
Reference in New Issue
Block a user