feat: group private apps into andreknie-privat

This commit is contained in:
2026-07-25 15:47:01 +02:00
parent 4c7d48ae6d
commit 2e83750cb7
109 changed files with 0 additions and 0 deletions
@@ -0,0 +1 @@
"""LLM-powered enrichment: entity detection, categorization, and action item extraction."""
@@ -0,0 +1,122 @@
"""Enrichment agent orchestrating LLM calls for entity detection.
Calls the LLM with extracted text, parses the structured response,
filters entities by confidence threshold, and handles parse errors with retry.
"""
import json
import logging
from ingestion.config import IngestionConfig
from ingestion.integrations.llm import LLMClient, LLMError
from .models import EnrichmentResult, Entity
from .prompts import STRICT_RETRY_PROMPT, build_system_prompt, build_user_prompt
logger = logging.getLogger(__name__)
class EnrichmentError(Exception):
"""Raised when enrichment fails after retries."""
pass
class EnrichmentAgent:
"""Orchestrates LLM-based document enrichment."""
def __init__(
self,
config: IngestionConfig,
llm_client: LLMClient | None = None,
known_projects: list[str] | None = None,
known_people: list[str] | None = None,
):
self.config = config
self.llm = llm_client or LLMClient(config)
self.confidence_threshold = config.confidence_threshold
self.system_prompt = build_system_prompt(known_projects, known_people)
def enrich(self, text: str) -> EnrichmentResult:
"""Enrich extracted text with LLM-detected entities and metadata.
Args:
text: The extracted document text to analyze.
Returns:
EnrichmentResult with entities filtered by confidence threshold.
Raises:
EnrichmentError: If LLM call and retry both fail.
"""
messages = [
{"role": "system", "content": self.system_prompt},
{"role": "user", "content": build_user_prompt(text)},
]
# First attempt
try:
response_text = self.llm.complete(messages)
result = self._parse_response(response_text)
except (json.JSONDecodeError, ValueError) as parse_err:
logger.warning(
"First LLM response parse failed (%s), retrying with strict prompt",
parse_err,
)
# Retry with stricter prompt
result = self._retry_with_strict_prompt(messages, response_text)
except LLMError as e:
raise EnrichmentError(f"LLM call failed: {e}") from e
# Filter entities by confidence threshold
result.entities = self._filter_by_confidence(result.entities)
return result
def _retry_with_strict_prompt(
self, original_messages: list[dict[str, str]], failed_response: str
) -> EnrichmentResult:
"""Retry LLM call with a stricter prompt after parse failure."""
retry_messages = original_messages + [
{"role": "assistant", "content": failed_response},
{"role": "user", "content": STRICT_RETRY_PROMPT},
]
try:
response_text = self.llm.complete(retry_messages)
return self._parse_response(response_text)
except (json.JSONDecodeError, ValueError, LLMError) as e:
logger.error("Retry also failed (%s), using defaults", e)
return self._default_result()
def _parse_response(self, response_text: str) -> EnrichmentResult:
"""Parse LLM response text into an EnrichmentResult.
Handles responses that may be wrapped in markdown code fences.
"""
cleaned = response_text.strip()
# Strip markdown code fences if present
if cleaned.startswith("```"):
lines = cleaned.split("\n")
# Remove first line (```json) and last line (```)
lines = [l for l in lines[1:] if not l.strip() == "```"]
cleaned = "\n".join(lines)
data = json.loads(cleaned)
return EnrichmentResult.model_validate(data)
def _filter_by_confidence(self, entities: list[Entity]) -> list[Entity]:
"""Keep only entities meeting the confidence threshold."""
return [e for e in entities if e.confidence >= self.confidence_threshold]
def _default_result(self) -> EnrichmentResult:
"""Return a minimal default result when all parsing fails."""
return EnrichmentResult(
title="Untitled Note",
category="inbox",
tags=[],
entities=[],
action_items=[],
summary=None,
)
@@ -0,0 +1,37 @@
"""Pydantic models for LLM enrichment responses.
Defines the structured output format expected from the enrichment agent:
entities, action items, and the overall enrichment result.
"""
from typing import Literal
from pydantic import BaseModel, Field
class Entity(BaseModel):
"""A detected entity in the document text."""
type: Literal["person", "project", "date", "action_item"]
value: str
confidence: float = Field(ge=0.0, le=1.0)
position: int | None = None # character offset in source text
class ActionItem(BaseModel):
"""An extracted action item / task."""
description: str
assignee: str | None = None
deadline: str | None = None # ISO 8601 date string
class EnrichmentResult(BaseModel):
"""Complete enrichment output from the LLM."""
title: str
category: Literal["meeting", "project", "decision", "inbox"]
tags: list[str] = Field(default_factory=list)
entities: list[Entity] = Field(default_factory=list)
action_items: list[ActionItem] = Field(default_factory=list)
summary: str | None = None
@@ -0,0 +1,89 @@
"""Structured LLM prompts for document enrichment.
Provides the system prompt that instructs the LLM to return structured JSON
with entities, categories, tags, and action items.
"""
ENRICHMENT_SYSTEM_PROMPT = """\
You are a knowledge management assistant. Analyze the following text extracted \
from a document and return structured metadata as JSON.
Return a JSON object with exactly these fields:
- "title": A concise, descriptive title for this note (max 80 chars)
- "category": One of "meeting", "project", "decision", "inbox"
- "tags": List of relevant topic tags (lowercase, no # prefix)
- "entities": List of detected entities, each with:
- "type": One of "person", "project", "date", "action_item"
- "value": The extracted text value
- "confidence": Float between 0.0 and 1.0 indicating detection confidence
- "position": Approximate character offset where entity appears (integer or null)
- "action_items": List of action items, each with:
- "description": What needs to be done
- "assignee": Person responsible (string or null)
- "deadline": Date in ISO 8601 format, e.g. "2024-03-22" (string or null)
- "summary": A 1-2 sentence summary of the document (string or null)
Rules:
- Be precise with entity detection. Only mark entities you are confident about.
- For people, use their full name as the value.
- For projects, use the project name as commonly referenced.
- For dates, use ISO 8601 format (YYYY-MM-DD) as the value.
- Assign confidence scores honestly: 0.9+ for clearly stated entities, 0.7-0.9 for \
likely entities, below 0.7 for uncertain ones.
- Category "meeting" for meeting notes, agendas, minutes.
- Category "project" for project-specific documentation.
- Category "decision" for recorded decisions, ADRs.
- Category "inbox" for anything that doesn't clearly fit the above.
Context: This is a personal knowledge base for a project manager working across \
multiple business projects.{context_section}
"""
STRICT_RETRY_PROMPT = """\
Your previous response was not valid JSON. Please respond with ONLY a valid JSON \
object matching the schema described in the system prompt. No markdown, no code fences, \
no explanation — just the raw JSON object.
"""
def build_system_prompt(
known_projects: list[str] | None = None,
known_people: list[str] | None = None,
) -> str:
"""Build the system prompt with optional context about known entities.
Args:
known_projects: List of known project names for context.
known_people: List of known people names for context.
Returns:
Complete system prompt string.
"""
context_parts: list[str] = []
if known_projects:
projects_str = ", ".join(known_projects)
context_parts.append(f"Known projects: {projects_str}.")
if known_people:
people_str = ", ".join(known_people)
context_parts.append(f"Known people: {people_str}.")
if context_parts:
context_section = "\n" + " ".join(context_parts)
else:
context_section = ""
return ENRICHMENT_SYSTEM_PROMPT.format(context_section=context_section)
def build_user_prompt(extracted_text: str) -> str:
"""Build the user message containing the extracted document text.
Args:
extracted_text: The raw text extracted from the document.
Returns:
User prompt string.
"""
return f"Analyze this document and return the structured JSON:\n\n{extracted_text}"