Add analysis engine: batch pipeline, interactive Q&A, context builder
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
from .claude_batch import BatchAnalyzer
|
||||||
|
from .claude_interactive import InteractiveAnalyzer
|
||||||
|
from .context_builder import ContextBuilder
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"""Batch document analysis pipeline for Mosaic."""
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from storage.db import MosaicDB
|
||||||
|
from storage.models import Document
|
||||||
|
from storage.jsonl_store import JSONLStore
|
||||||
|
from utils.api_limiter import APIRateLimiter, CircuitBreakerOpen
|
||||||
|
from utils.text_utils import estimate_tokens
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
EXTRACTOR_DOMAINS = {
|
||||||
|
'tools': 'tool',
|
||||||
|
'ttps': 'ttp',
|
||||||
|
'tradecraft': 'tradecraft',
|
||||||
|
'surveillance': 'surveillance',
|
||||||
|
'infrastructure': 'infrastructure',
|
||||||
|
'entities': 'entity',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BatchAnalyzer:
|
||||||
|
"""Run batch extraction pipeline across documents."""
|
||||||
|
|
||||||
|
def __init__(self, db: MosaicDB, api_key: str,
|
||||||
|
model: str = "claude-sonnet-4-20250514",
|
||||||
|
rate_limiter: Optional[APIRateLimiter] = None):
|
||||||
|
self.db = db
|
||||||
|
self.api_key = api_key
|
||||||
|
self.model = model
|
||||||
|
self.rate_limiter = rate_limiter or APIRateLimiter()
|
||||||
|
self._extractors = {}
|
||||||
|
|
||||||
|
def _get_extractor(self, domain: str):
|
||||||
|
"""Lazy-load extractor by domain name."""
|
||||||
|
if domain not in self._extractors:
|
||||||
|
from extractors.ttp_extractor import TTPExtractor
|
||||||
|
from extractors.tool_extractor import ToolExtractor
|
||||||
|
from extractors.tradecraft import TradecraftExtractor
|
||||||
|
from extractors.surveillance import SurveillanceExtractor
|
||||||
|
from extractors.infrastructure import InfrastructureExtractor
|
||||||
|
from extractors.entity_extractor import EntityExtractor
|
||||||
|
|
||||||
|
extractor_classes = {
|
||||||
|
'ttp': TTPExtractor,
|
||||||
|
'tool': ToolExtractor,
|
||||||
|
'tradecraft': TradecraftExtractor,
|
||||||
|
'surveillance': SurveillanceExtractor,
|
||||||
|
'infrastructure': InfrastructureExtractor,
|
||||||
|
'entity': EntityExtractor,
|
||||||
|
}
|
||||||
|
|
||||||
|
cls = extractor_classes.get(domain)
|
||||||
|
if cls:
|
||||||
|
if domain == 'infrastructure':
|
||||||
|
self._extractors[domain] = cls(
|
||||||
|
db=self.db, api_key=self.api_key,
|
||||||
|
model=self.model, rate_limiter=self.rate_limiter,
|
||||||
|
collection="", doc_title="", doc_date=None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._extractors[domain] = cls(
|
||||||
|
db=self.db, api_key=self.api_key,
|
||||||
|
model=self.model, rate_limiter=self.rate_limiter,
|
||||||
|
)
|
||||||
|
return self._extractors.get(domain)
|
||||||
|
|
||||||
|
def estimate(self, source: Optional[str] = None,
|
||||||
|
domains: Optional[list[str]] = None,
|
||||||
|
since: Optional[str] = None,
|
||||||
|
until: Optional[str] = None,
|
||||||
|
since_last_run: bool = False) -> dict:
|
||||||
|
"""Estimate token costs without running extraction (dry-run).
|
||||||
|
|
||||||
|
Returns dict with document count, chunk count, estimated tokens.
|
||||||
|
"""
|
||||||
|
domains = domains or list(EXTRACTOR_DOMAINS.values())
|
||||||
|
docs = self._get_target_documents(source, since, until, since_last_run)
|
||||||
|
|
||||||
|
total_chunks = 0
|
||||||
|
total_input_tokens = 0
|
||||||
|
|
||||||
|
for doc in docs:
|
||||||
|
for domain in domains:
|
||||||
|
extractor = self._get_extractor(domain)
|
||||||
|
if extractor:
|
||||||
|
est = extractor.estimate_tokens(doc.text, doc.doc_type)
|
||||||
|
total_chunks += est['chunks']
|
||||||
|
total_input_tokens += est['estimated_input_tokens']
|
||||||
|
|
||||||
|
return {
|
||||||
|
'document_count': len(docs),
|
||||||
|
'domain_count': len(domains),
|
||||||
|
'total_chunks': total_chunks,
|
||||||
|
'estimated_input_tokens': total_input_tokens,
|
||||||
|
'estimated_output_tokens': total_chunks * 2000,
|
||||||
|
'estimated_total_tokens': total_input_tokens + (total_chunks * 2000),
|
||||||
|
}
|
||||||
|
|
||||||
|
def run(self, source: Optional[str] = None,
|
||||||
|
domains: Optional[list[str]] = None,
|
||||||
|
since: Optional[str] = None,
|
||||||
|
until: Optional[str] = None,
|
||||||
|
since_last_run: bool = False,
|
||||||
|
retry_failed: bool = False) -> dict:
|
||||||
|
"""Run batch extraction pipeline.
|
||||||
|
|
||||||
|
Returns dict with counts of extracted items per domain.
|
||||||
|
"""
|
||||||
|
domains = domains or list(EXTRACTOR_DOMAINS.values())
|
||||||
|
docs = self._get_target_documents(source, since, until, since_last_run)
|
||||||
|
|
||||||
|
if not docs:
|
||||||
|
console.print("[yellow]No documents to process[/yellow]")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
results = {d: 0 for d in domains}
|
||||||
|
|
||||||
|
with Progress(
|
||||||
|
SpinnerColumn(),
|
||||||
|
TextColumn("[progress.description]{task.description}"),
|
||||||
|
BarColumn(),
|
||||||
|
TaskProgressColumn(),
|
||||||
|
) as progress:
|
||||||
|
for domain in domains:
|
||||||
|
extractor = self._get_extractor(domain)
|
||||||
|
if not extractor:
|
||||||
|
console.print(f"[yellow]Unknown domain: {domain}[/yellow]")
|
||||||
|
continue
|
||||||
|
|
||||||
|
task = progress.add_task(f"Extracting {domain}", total=len(docs))
|
||||||
|
|
||||||
|
for doc in docs:
|
||||||
|
# Check if already done
|
||||||
|
status = self.db.get_extraction_status(doc.id, extractor.EXTRACTOR_NAME)
|
||||||
|
if status and status.status == 'DONE' and not retry_failed:
|
||||||
|
progress.advance(task)
|
||||||
|
continue
|
||||||
|
if status and status.status == 'FAILED' and not retry_failed:
|
||||||
|
progress.advance(task)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Update infrastructure extractor context
|
||||||
|
if domain == 'infrastructure' and hasattr(extractor, 'collection'):
|
||||||
|
extractor.collection = doc.source
|
||||||
|
extractor.doc_title = doc.title
|
||||||
|
extractor.doc_date = doc.date
|
||||||
|
|
||||||
|
count = extractor.extract_from_document(
|
||||||
|
doc_id=doc.id,
|
||||||
|
text=doc.text,
|
||||||
|
doc_type=doc.doc_type,
|
||||||
|
source=doc.source,
|
||||||
|
metadata=doc.metadata,
|
||||||
|
)
|
||||||
|
results[domain] += count
|
||||||
|
|
||||||
|
except CircuitBreakerOpen:
|
||||||
|
console.print(f"[red]Circuit breaker open — stopping {domain} extraction[/red]")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Extraction error for %s on doc %s: %s",
|
||||||
|
domain, doc.id[:12], e)
|
||||||
|
|
||||||
|
progress.advance(task)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def run_full_pipeline(self, source: str, limit: Optional[int] = None) -> dict:
|
||||||
|
"""Run full pipeline: collect → parse → extract → report.
|
||||||
|
|
||||||
|
Returns summary dict.
|
||||||
|
"""
|
||||||
|
from collectors import load_profile, CustomSource
|
||||||
|
from parsers import ParserManager
|
||||||
|
from utils.cache import DownloadCache
|
||||||
|
from utils.http_limiter import HTTPRateLimiter
|
||||||
|
|
||||||
|
summary = {'collect': {}, 'parse': {}, 'extract': {}, 'export': []}
|
||||||
|
|
||||||
|
# Step 1: Collect
|
||||||
|
console.print(f"\n[bold cyan]Step 1/4: Collecting from {source}[/bold cyan]")
|
||||||
|
try:
|
||||||
|
profile = load_profile(source)
|
||||||
|
cache = DownloadCache()
|
||||||
|
collector = CustomSource(profile, self.db, cache)
|
||||||
|
summary['collect'] = collector.collect(limit=limit)
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[red]Collection error: {e}[/red]")
|
||||||
|
return summary
|
||||||
|
|
||||||
|
# Step 2: Parse
|
||||||
|
console.print(f"\n[bold cyan]Step 2/4: Parsing documents[/bold cyan]")
|
||||||
|
parser = ParserManager()
|
||||||
|
docs = self.db.get_documents_by_source(source, status='CACHED')
|
||||||
|
parsed = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
with Progress(SpinnerColumn(), TextColumn("{task.description}"),
|
||||||
|
BarColumn(), TaskProgressColumn()) as progress:
|
||||||
|
task = progress.add_task("Parsing", total=len(docs))
|
||||||
|
for doc in docs:
|
||||||
|
if doc.parse_status in ('PARSED', 'NEEDS_OCR'):
|
||||||
|
progress.advance(task)
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.db.update_document_status(doc.id, parse_status='IN_PROGRESS')
|
||||||
|
result = parser.parse(doc.raw_path, doc.doc_type, doc.content_hash)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
doc.text = result.text
|
||||||
|
doc.title = result.title or doc.title
|
||||||
|
doc.char_count = result.char_count
|
||||||
|
doc.metadata.update(result.metadata)
|
||||||
|
status = 'NEEDS_OCR' if result.needs_ocr else 'PARSED'
|
||||||
|
doc.parse_status = status
|
||||||
|
self.db.insert_document(doc)
|
||||||
|
self.db.update_document_status(doc.id, parse_status=status)
|
||||||
|
parsed += 1
|
||||||
|
else:
|
||||||
|
self.db.update_document_status(doc.id, parse_status='PARSE_FAILED')
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
progress.advance(task)
|
||||||
|
|
||||||
|
summary['parse'] = {'parsed': parsed, 'failed': failed}
|
||||||
|
|
||||||
|
# Step 3: Extract
|
||||||
|
console.print(f"\n[bold cyan]Step 3/4: Running extraction[/bold cyan]")
|
||||||
|
summary['extract'] = self.run(source=source)
|
||||||
|
|
||||||
|
# Step 4: Export
|
||||||
|
console.print(f"\n[bold cyan]Step 4/4: Exporting results[/bold cyan]")
|
||||||
|
store = JSONLStore(self.db)
|
||||||
|
summary['export'] = store.export()
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
def _get_target_documents(self, source: Optional[str] = None,
|
||||||
|
since: Optional[str] = None,
|
||||||
|
until: Optional[str] = None,
|
||||||
|
since_last_run: bool = False) -> list[Document]:
|
||||||
|
"""Get documents to process based on filters."""
|
||||||
|
if since or until:
|
||||||
|
return self.db.get_documents_by_date_range(source=source, since=since, until=until)
|
||||||
|
elif since_last_run:
|
||||||
|
return self.db.get_unextracted_documents(source=source, since_last_run=True)
|
||||||
|
elif source:
|
||||||
|
return self.db.get_documents_by_source(source, parse_status='PARSED')
|
||||||
|
else:
|
||||||
|
return self.db.get_unextracted_documents()
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"""Interactive Q&A mode for Mosaic — Rich-based chat over extracted intelligence."""
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import anthropic
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.markdown import Markdown
|
||||||
|
from rich.prompt import Prompt
|
||||||
|
|
||||||
|
from storage.db import MosaicDB
|
||||||
|
from .context_builder import ContextBuilder
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
# Import prompts — handle if not yet created
|
||||||
|
try:
|
||||||
|
from .prompts import (
|
||||||
|
QUERY_SYSTEM_PROMPT, QUERY_PROMPT, EMULATE_PROMPT,
|
||||||
|
COMPARE_PROMPT, BRIEF_PROMPT, SCENARIO_PROMPT
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
QUERY_SYSTEM_PROMPT = "You are an intelligence analyst. Answer questions based on provided documents."
|
||||||
|
QUERY_PROMPT = "{context}\n\nQuestion: {question}"
|
||||||
|
EMULATE_PROMPT = "Emulate threat actor {actor}:\n{context}"
|
||||||
|
COMPARE_PROMPT = "Compare {tool_a} vs {tool_b}:\n{context}"
|
||||||
|
BRIEF_PROMPT = "Brief on {topic}:\n{context}"
|
||||||
|
SCENARIO_PROMPT = "Scenario for {target}:\n{context}"
|
||||||
|
|
||||||
|
|
||||||
|
class InteractiveAnalyzer:
|
||||||
|
"""Interactive Q&A over extracted intelligence corpus."""
|
||||||
|
|
||||||
|
def __init__(self, db: MosaicDB, api_key: str,
|
||||||
|
model: str = "claude-sonnet-4-20250514",
|
||||||
|
source: Optional[str] = None):
|
||||||
|
self.db = db
|
||||||
|
self.client = anthropic.Anthropic(api_key=api_key)
|
||||||
|
self.model = model
|
||||||
|
self.source = source
|
||||||
|
self.context_builder = ContextBuilder(db)
|
||||||
|
self.mode = "default"
|
||||||
|
self.conversation_history = []
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Start interactive Q&A session."""
|
||||||
|
self._show_welcome()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
user_input = Prompt.ask("\n[bold green]mosaic[/bold green]")
|
||||||
|
except (KeyboardInterrupt, EOFError):
|
||||||
|
console.print("\n[dim]Goodbye.[/dim]")
|
||||||
|
break
|
||||||
|
|
||||||
|
user_input = user_input.strip()
|
||||||
|
if not user_input:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Handle commands
|
||||||
|
if user_input.lower() in ('exit', 'quit', 'q'):
|
||||||
|
console.print("[dim]Goodbye.[/dim]")
|
||||||
|
break
|
||||||
|
elif user_input.lower() == 'help':
|
||||||
|
self._show_help()
|
||||||
|
continue
|
||||||
|
elif user_input.lower().startswith('mode '):
|
||||||
|
self.mode = user_input[5:].strip()
|
||||||
|
console.print(f"[cyan]Mode set to: {self.mode}[/cyan]")
|
||||||
|
continue
|
||||||
|
elif user_input.lower().startswith('source '):
|
||||||
|
self.source = user_input[7:].strip()
|
||||||
|
console.print(f"[cyan]Source filter: {self.source}[/cyan]")
|
||||||
|
continue
|
||||||
|
elif user_input.lower() == 'clear':
|
||||||
|
self.conversation_history = []
|
||||||
|
console.print("[dim]Conversation cleared.[/dim]")
|
||||||
|
continue
|
||||||
|
elif user_input.lower() == 'status':
|
||||||
|
self._show_status()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Process query based on mode
|
||||||
|
self._handle_query(user_input)
|
||||||
|
|
||||||
|
def _handle_query(self, query: str):
|
||||||
|
"""Process a query in the current mode."""
|
||||||
|
with console.status("[bold cyan]Searching corpus...[/bold cyan]"):
|
||||||
|
# Build context based on mode
|
||||||
|
if self.mode == 'emulate':
|
||||||
|
context = self.context_builder.build_context(query, self.source)
|
||||||
|
prompt = EMULATE_PROMPT.format(actor=query, context=context['context_text'])
|
||||||
|
elif self.mode == 'compare' and ' vs ' in query:
|
||||||
|
parts = query.split(' vs ', 1)
|
||||||
|
tool_a = parts[0].strip()
|
||||||
|
tool_b = parts[1].strip()
|
||||||
|
ctx_a = self.context_builder.build_tool_context(tool_a)
|
||||||
|
ctx_b = self.context_builder.build_tool_context(tool_b)
|
||||||
|
context = {'context_text': ctx_a + '\n\n' + ctx_b, 'documents': [], 'total_tokens': 0}
|
||||||
|
prompt = COMPARE_PROMPT.format(tool_a=tool_a, tool_b=tool_b, context=context['context_text'])
|
||||||
|
elif self.mode == 'brief':
|
||||||
|
context = self.context_builder.build_context(query, self.source)
|
||||||
|
prompt = BRIEF_PROMPT.format(topic=query, context=context['context_text'])
|
||||||
|
elif self.mode == 'scenario':
|
||||||
|
context = self.context_builder.build_context(query, self.source)
|
||||||
|
prompt = SCENARIO_PROMPT.format(target=query, context=context['context_text'])
|
||||||
|
else:
|
||||||
|
context = self.context_builder.build_context(query, self.source)
|
||||||
|
prompt = QUERY_PROMPT.format(context=context['context_text'], question=query)
|
||||||
|
|
||||||
|
if not context.get('context_text'):
|
||||||
|
console.print("[yellow]No relevant documents found for your query.[/yellow]")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Show source count
|
||||||
|
doc_count = len(context.get('documents', []))
|
||||||
|
console.print(f"[dim]Found {doc_count} relevant documents, "
|
||||||
|
f"~{context.get('total_tokens', 0):,} tokens of context[/dim]")
|
||||||
|
|
||||||
|
# Call Claude
|
||||||
|
with console.status("[bold cyan]Analyzing...[/bold cyan]"):
|
||||||
|
try:
|
||||||
|
messages = list(self.conversation_history)
|
||||||
|
messages.append({"role": "user", "content": prompt})
|
||||||
|
|
||||||
|
response = self.client.messages.create(
|
||||||
|
model=self.model,
|
||||||
|
max_tokens=4096,
|
||||||
|
system=QUERY_SYSTEM_PROMPT,
|
||||||
|
messages=messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
answer = response.content[0].text
|
||||||
|
|
||||||
|
# Save to conversation history
|
||||||
|
self.conversation_history.append({"role": "user", "content": prompt})
|
||||||
|
self.conversation_history.append({"role": "assistant", "content": answer})
|
||||||
|
|
||||||
|
# Trim history if too long
|
||||||
|
if len(self.conversation_history) > 20:
|
||||||
|
self.conversation_history = self.conversation_history[-10:]
|
||||||
|
|
||||||
|
except anthropic.APIError as e:
|
||||||
|
console.print(f"[red]API error: {e}[/red]")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Display response
|
||||||
|
console.print()
|
||||||
|
console.print(Panel(
|
||||||
|
Markdown(answer),
|
||||||
|
title="[bold]Analysis[/bold]",
|
||||||
|
border_style="cyan",
|
||||||
|
))
|
||||||
|
|
||||||
|
# Show citations
|
||||||
|
if context.get('documents'):
|
||||||
|
console.print("\n[dim]Sources:[/dim]")
|
||||||
|
for doc in context['documents'][:5]:
|
||||||
|
console.print(f" [dim]- {doc['source']}: {doc['title']}", markup=False)
|
||||||
|
|
||||||
|
def _show_welcome(self):
|
||||||
|
"""Show welcome message."""
|
||||||
|
welcome = """[bold cyan]MOSAIC Interactive Intelligence Query[/bold cyan]
|
||||||
|
|
||||||
|
Ask questions about the extracted intelligence corpus.
|
||||||
|
Type [bold]help[/bold] for commands, [bold]exit[/bold] to quit."""
|
||||||
|
|
||||||
|
if self.source:
|
||||||
|
welcome += f"\n[dim]Source filter: {self.source}[/dim]"
|
||||||
|
|
||||||
|
console.print(Panel(welcome, border_style="cyan"))
|
||||||
|
|
||||||
|
def _show_help(self):
|
||||||
|
"""Show help text."""
|
||||||
|
help_text = """[bold]Commands:[/bold]
|
||||||
|
help Show this help
|
||||||
|
exit/quit Leave interactive mode
|
||||||
|
clear Clear conversation history
|
||||||
|
status Show corpus statistics
|
||||||
|
mode <mode> Set query mode:
|
||||||
|
default - General Q&A
|
||||||
|
emulate - Threat actor emulation playbook
|
||||||
|
compare - Compare tools (use 'tool_a vs tool_b')
|
||||||
|
brief - Intelligence briefing
|
||||||
|
scenario - Adversary simulation scenario
|
||||||
|
source <name> Filter to specific source (or 'all')
|
||||||
|
|
||||||
|
[bold]Tips:[/bold]
|
||||||
|
- Ask specific questions for better results
|
||||||
|
- Use source names (vault7, cablegate) to focus queries
|
||||||
|
- Emulate mode: type an actor name to get their TTP playbook"""
|
||||||
|
console.print(Panel(help_text, border_style="cyan"))
|
||||||
|
|
||||||
|
def _show_status(self):
|
||||||
|
"""Show corpus statistics."""
|
||||||
|
stats = self.db.get_status_counts(self.source)
|
||||||
|
tokens = self.db.get_total_tokens(self.source)
|
||||||
|
|
||||||
|
console.print(f"\n[bold]Corpus Statistics[/bold]")
|
||||||
|
console.print(f" Documents: {stats.get('total', 0)}")
|
||||||
|
console.print(f" Parsed: {stats.get('parsing', {}).get('PARSED', 0)}")
|
||||||
|
console.print(f" Tokens used: {tokens.get('total_tokens', 0):,}")
|
||||||
|
|
||||||
|
if self.source:
|
||||||
|
console.print(f" [dim]Filtered to: {self.source}[/dim]")
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""Context builder for assembling relevant document chunks for Claude Q&A."""
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from storage.db import MosaicDB
|
||||||
|
from utils.text_utils import estimate_tokens
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_TOKEN_BUDGET = 30000
|
||||||
|
DEFAULT_MAX_RESULTS = 20
|
||||||
|
|
||||||
|
|
||||||
|
class ContextBuilder:
|
||||||
|
"""Build context from FTS5 search results for Claude queries.
|
||||||
|
|
||||||
|
Uses BM25 ranking from FTS5, assembles chunks within a token budget,
|
||||||
|
and tracks source citations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db: MosaicDB, token_budget: int = DEFAULT_TOKEN_BUDGET,
|
||||||
|
max_results: int = DEFAULT_MAX_RESULTS):
|
||||||
|
self.db = db
|
||||||
|
self.token_budget = token_budget
|
||||||
|
self.max_results = max_results
|
||||||
|
|
||||||
|
def build_context(self, query: str, source: Optional[str] = None) -> dict:
|
||||||
|
"""Search documents and assemble context within token budget.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query string
|
||||||
|
source: Optional source filter
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with:
|
||||||
|
'context_text': assembled context string with citations
|
||||||
|
'documents': list of matched document metadata
|
||||||
|
'total_tokens': estimated token count
|
||||||
|
'truncated': whether results were truncated
|
||||||
|
"""
|
||||||
|
# Search with FTS5
|
||||||
|
docs = self.db.search_documents(query, source=source, limit=self.max_results)
|
||||||
|
|
||||||
|
if not docs:
|
||||||
|
return {
|
||||||
|
'context_text': '',
|
||||||
|
'documents': [],
|
||||||
|
'total_tokens': 0,
|
||||||
|
'truncated': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Assemble context within budget
|
||||||
|
context_parts = []
|
||||||
|
used_tokens = 0
|
||||||
|
included_docs = []
|
||||||
|
truncated = False
|
||||||
|
|
||||||
|
for doc in docs:
|
||||||
|
# Build document excerpt
|
||||||
|
excerpt = self._format_document_excerpt(doc)
|
||||||
|
excerpt_tokens = estimate_tokens(excerpt)
|
||||||
|
|
||||||
|
if used_tokens + excerpt_tokens > self.token_budget:
|
||||||
|
# Try to fit a truncated version
|
||||||
|
remaining_tokens = self.token_budget - used_tokens
|
||||||
|
if remaining_tokens > 500: # Worth including a partial
|
||||||
|
truncated_excerpt = self._truncate_to_tokens(excerpt, remaining_tokens)
|
||||||
|
context_parts.append(truncated_excerpt)
|
||||||
|
used_tokens += remaining_tokens
|
||||||
|
included_docs.append(self._doc_metadata(doc))
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
|
||||||
|
context_parts.append(excerpt)
|
||||||
|
used_tokens += excerpt_tokens
|
||||||
|
included_docs.append(self._doc_metadata(doc))
|
||||||
|
|
||||||
|
context_text = '\n\n---\n\n'.join(context_parts)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'context_text': context_text,
|
||||||
|
'documents': included_docs,
|
||||||
|
'total_tokens': used_tokens,
|
||||||
|
'truncated': truncated,
|
||||||
|
}
|
||||||
|
|
||||||
|
def build_entity_context(self, entity_name: str) -> str:
|
||||||
|
"""Build context focused on a specific entity across all sources."""
|
||||||
|
docs = self.db.search_documents(entity_name, limit=self.max_results)
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
used_tokens = 0
|
||||||
|
for doc in docs:
|
||||||
|
excerpt = self._format_document_excerpt(doc)
|
||||||
|
tokens = estimate_tokens(excerpt)
|
||||||
|
if used_tokens + tokens > self.token_budget:
|
||||||
|
break
|
||||||
|
parts.append(excerpt)
|
||||||
|
used_tokens += tokens
|
||||||
|
|
||||||
|
return '\n\n---\n\n'.join(parts)
|
||||||
|
|
||||||
|
def build_tool_context(self, tool_name: str) -> str:
|
||||||
|
"""Build context focused on a specific tool/implant."""
|
||||||
|
# Search both FTS and extracted tools
|
||||||
|
docs = self.db.search_documents(tool_name, limit=self.max_results)
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
used_tokens = 0
|
||||||
|
|
||||||
|
# Add extracted tool data if available
|
||||||
|
tools = self.db.get_all_tools()
|
||||||
|
for tool in tools:
|
||||||
|
if tool_name.lower() in tool.name.lower() or \
|
||||||
|
any(tool_name.lower() in a.lower() for a in tool.aliases):
|
||||||
|
tool_text = f"[Extracted Tool: {tool.name}]\n"
|
||||||
|
tool_text += f"Capability: {tool.capability}\n"
|
||||||
|
tool_text += f"Description: {tool.description}\n"
|
||||||
|
tool_text += f"Platforms: {', '.join(tool.target_platforms)}\n"
|
||||||
|
tool_text += f"CVEs: {', '.join(tool.cves)}\n"
|
||||||
|
tool_text += f"Source context: {tool.source_context}\n"
|
||||||
|
parts.append(tool_text)
|
||||||
|
used_tokens += estimate_tokens(tool_text)
|
||||||
|
|
||||||
|
# Add document excerpts
|
||||||
|
for doc in docs:
|
||||||
|
excerpt = self._format_document_excerpt(doc)
|
||||||
|
tokens = estimate_tokens(excerpt)
|
||||||
|
if used_tokens + tokens > self.token_budget:
|
||||||
|
break
|
||||||
|
parts.append(excerpt)
|
||||||
|
used_tokens += tokens
|
||||||
|
|
||||||
|
return '\n\n---\n\n'.join(parts)
|
||||||
|
|
||||||
|
def _format_document_excerpt(self, doc) -> str:
|
||||||
|
"""Format a document for context inclusion with citation info."""
|
||||||
|
header = f"[Source: {doc.source} | Title: {doc.title}"
|
||||||
|
if doc.date:
|
||||||
|
header += f" | Date: {doc.date}"
|
||||||
|
if doc.classification:
|
||||||
|
header += f" | Classification: {doc.classification}"
|
||||||
|
header += f" | ID: {doc.id[:12]}]"
|
||||||
|
|
||||||
|
# Truncate text if needed
|
||||||
|
text = doc.text
|
||||||
|
max_chars = (self.token_budget // len(self.db.search_documents("", limit=1) or [doc])) * 4
|
||||||
|
if len(text) > max_chars:
|
||||||
|
text = text[:max_chars] + "\n[...truncated...]"
|
||||||
|
|
||||||
|
return f"{header}\n\n{text}"
|
||||||
|
|
||||||
|
def _truncate_to_tokens(self, text: str, max_tokens: int) -> str:
|
||||||
|
"""Truncate text to approximately max_tokens."""
|
||||||
|
max_chars = max_tokens * 4 # Rough chars-per-token estimate
|
||||||
|
if len(text) <= max_chars:
|
||||||
|
return text
|
||||||
|
return text[:max_chars] + "\n[...truncated...]"
|
||||||
|
|
||||||
|
def _doc_metadata(self, doc) -> dict:
|
||||||
|
"""Extract metadata dict from a document for citation tracking."""
|
||||||
|
return {
|
||||||
|
'id': doc.id,
|
||||||
|
'title': doc.title,
|
||||||
|
'source': doc.source,
|
||||||
|
'date': doc.date,
|
||||||
|
'doc_type': doc.doc_type,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user