Migrate all repos into monorepo context folders
Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)
Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
This commit is contained in:
@@ -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}"
|
||||
Reference in New Issue
Block a user