229 lines
7.8 KiB
Python
229 lines
7.8 KiB
Python
"""
|
|
LLM client for fact extraction and other AI-powered operations.
|
|
|
|
Uses OpenAI-compatible API (works with Groq, OpenAI, etc.)
|
|
"""
|
|
import os
|
|
import json
|
|
import re
|
|
from typing import List, Dict, Optional, Literal
|
|
from openai import AsyncOpenAI
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
class ExtractedFact(BaseModel):
|
|
"""A single extracted fact from text."""
|
|
fact: str = Field(
|
|
description="Self-contained factual statement with subject + action + context"
|
|
)
|
|
speaker: str = Field(
|
|
default="narrator",
|
|
description="Who said this (name or 'narrator' if not a conversation)"
|
|
)
|
|
type: Literal["biographical", "event", "opinion", "recommendation", "description", "relationship"] = Field(
|
|
description="Category of the fact"
|
|
)
|
|
confidence: Literal["high", "medium", "low"] = Field(
|
|
default="medium",
|
|
description="Confidence level of the extraction"
|
|
)
|
|
|
|
|
|
class FactExtractionResponse(BaseModel):
|
|
"""Response containing all extracted facts."""
|
|
facts: List[ExtractedFact] = Field(
|
|
description="List of extracted factual statements"
|
|
)
|
|
|
|
|
|
def split_into_sentences(text: str) -> List[str]:
|
|
"""
|
|
Fast sentence splitter using regex.
|
|
Splits on periods, exclamation marks, and question marks followed by whitespace or end of string.
|
|
|
|
Args:
|
|
text: Input text to split
|
|
|
|
Returns:
|
|
List of sentences
|
|
"""
|
|
# Split on sentence boundaries: .!? followed by space/newline/end
|
|
sentences = re.split(r'(?<=[.!?])\s+', text)
|
|
return [s.strip() for s in sentences if s.strip()]
|
|
|
|
|
|
def chunk_text(text: str, max_chars: int = 120000) -> List[str]:
|
|
"""
|
|
Split text into chunks at sentence boundaries.
|
|
|
|
Keeps chunks under max_chars (~30k tokens assuming 1 token ≈ 4 chars).
|
|
This prevents hitting output token limits on large documents.
|
|
|
|
Args:
|
|
text: Input text to chunk
|
|
max_chars: Maximum characters per chunk (default 120k ≈ 30k tokens)
|
|
|
|
Returns:
|
|
List of text chunks, each under max_chars
|
|
"""
|
|
# If text is small enough, return as-is
|
|
if len(text) <= max_chars:
|
|
return [text]
|
|
|
|
sentences = split_into_sentences(text)
|
|
chunks = []
|
|
current_chunk = []
|
|
current_length = 0
|
|
|
|
for sentence in sentences:
|
|
sentence_length = len(sentence)
|
|
|
|
# If single sentence exceeds max_chars, split it forcefully
|
|
if sentence_length > max_chars:
|
|
# Save current chunk if any
|
|
if current_chunk:
|
|
chunks.append(' '.join(current_chunk))
|
|
current_chunk = []
|
|
current_length = 0
|
|
|
|
# Split long sentence into smaller pieces
|
|
for i in range(0, len(sentence), max_chars):
|
|
chunks.append(sentence[i:i + max_chars])
|
|
continue
|
|
|
|
# If adding this sentence would exceed limit, start new chunk
|
|
if current_length + sentence_length + 1 > max_chars:
|
|
chunks.append(' '.join(current_chunk))
|
|
current_chunk = [sentence]
|
|
current_length = sentence_length
|
|
else:
|
|
current_chunk.append(sentence)
|
|
current_length += sentence_length + 1 # +1 for space
|
|
|
|
# Add remaining chunk
|
|
if current_chunk:
|
|
chunks.append(' '.join(current_chunk))
|
|
|
|
return chunks
|
|
|
|
|
|
def get_llm_client() -> AsyncOpenAI:
|
|
"""
|
|
Get configured async LLM client.
|
|
|
|
Supports:
|
|
- Groq (default): Set GROQ_API_KEY and optionally GROQ_BASE_URL
|
|
- OpenAI: Set OPENAI_API_KEY
|
|
|
|
Returns:
|
|
Configured AsyncOpenAI client
|
|
"""
|
|
# Check for Groq configuration first
|
|
groq_api_key = os.getenv('GROQ_API_KEY')
|
|
if groq_api_key:
|
|
base_url = os.getenv('GROQ_BASE_URL', 'https://api.groq.com/openai/v1')
|
|
return AsyncOpenAI(
|
|
api_key=groq_api_key,
|
|
base_url=base_url
|
|
)
|
|
|
|
# Fall back to OpenAI
|
|
openai_api_key = os.getenv('OPENAI_API_KEY')
|
|
if openai_api_key:
|
|
return AsyncOpenAI(api_key=openai_api_key)
|
|
|
|
raise ValueError(
|
|
"No LLM API key found. Set GROQ_API_KEY or OPENAI_API_KEY environment variable."
|
|
)
|
|
|
|
|
|
async def extract_facts_from_text(
|
|
text: str,
|
|
model: str = "openai/gpt-oss-20b",
|
|
temperature: float = 0.1,
|
|
max_tokens: int = 65000,
|
|
chunk_size: int = 60000
|
|
) -> List[Dict[str, str]]:
|
|
client = get_llm_client()
|
|
|
|
# Chunk text if necessary
|
|
chunks = chunk_text(text, max_chars=chunk_size)
|
|
|
|
all_facts = []
|
|
|
|
for i, chunk in enumerate(chunks):
|
|
prompt = f"""You are extracting facts from text for an AI memory system. Each fact will be stored and retrieved later.
|
|
|
|
## CRITICAL: Facts must be DETAILED and COMPREHENSIVE
|
|
|
|
Each fact should:
|
|
1. Be SELF-CONTAINED - readable without the original context
|
|
2. Include ALL relevant details: WHO, WHAT, WHERE, WHEN, WHY, HOW
|
|
3. Preserve specific names, dates, numbers, locations, relationships
|
|
4. Resolve pronouns to actual names/entities
|
|
5. Include surrounding context that makes the fact meaningful
|
|
6. Capture nuances, reasons, causes, and implications
|
|
|
|
## What to EXTRACT:
|
|
- Biographical information (jobs, roles, backgrounds, experiences)
|
|
- Events (what happened, when, where, who was involved, why)
|
|
- Opinions and beliefs (who believes what and why)
|
|
- Recommendations and advice (specific suggestions with reasoning)
|
|
- Descriptions (detailed explanations of how things work)
|
|
- Relationships (connections between people, organizations, concepts)
|
|
|
|
## What to SKIP:
|
|
- Greetings, thank yous, acknowledgments
|
|
- Filler words ("um", "uh", "like")
|
|
- Pure reactions without content ("wow", "cool")
|
|
- Incomplete thoughts
|
|
|
|
## EXAMPLES of GOOD facts (detailed, comprehensive):
|
|
|
|
Input: "Alice mentioned she works at Google in Mountain View. She joined the AI team last year and loves working on large language models."
|
|
GOOD: "Alice works at Google in Mountain View on the AI team, which she joined last year, and she loves working on large language models"
|
|
BAD: "Alice works at Google" (too short, missing context)
|
|
|
|
Input: "Bob said he's been hiking every weekend in Yosemite because it helps him clear his mind after stressful work weeks."
|
|
GOOD: "Bob has been hiking every weekend in Yosemite because it helps him clear his mind after stressful work weeks"
|
|
BAD: "Bob hikes in Yosemite" (missing frequency, reason, and context)
|
|
|
|
Input: "The new algorithm reduced latency by 40% compared to the baseline by using a novel caching strategy."
|
|
GOOD: "The new algorithm reduced latency by 40% compared to the baseline by using a novel caching strategy"
|
|
BAD: "The algorithm is faster" (missing numbers, comparison, and method)
|
|
|
|
## TEXT TO EXTRACT FROM:
|
|
{chunk}
|
|
|
|
Remember: Include ALL details, names, numbers, reasons, and context. Facts should be rich and informative, not summaries."""
|
|
|
|
# Use parse() for structured outputs with Pydantic models
|
|
response = await client.beta.chat.completions.parse(
|
|
model=model,
|
|
messages=[
|
|
{
|
|
"role": "system",
|
|
"content": "You extract detailed, comprehensive facts from text. Preserve all context, details, and nuances. Never summarize or shorten - include everything relevant."
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": prompt
|
|
}
|
|
],
|
|
temperature=temperature,
|
|
max_tokens=max_tokens,
|
|
response_format=FactExtractionResponse
|
|
)
|
|
|
|
# Extract the parsed response
|
|
extraction_response = response.choices[0].message.parsed
|
|
|
|
# Convert to dict format and add to aggregate
|
|
chunk_facts = [fact.model_dump() for fact in extraction_response.facts]
|
|
all_facts.extend(chunk_facts)
|
|
|
|
# Log progress for large documents
|
|
if len(chunks) > 1:
|
|
print(f"Processed chunk {i + 1}/{len(chunks)}: extracted {len(chunk_facts)} facts")
|
|
|
|
return all_facts
|