Add analysis engine: batch pipeline, interactive Q&A, context builder

This commit is contained in:
n0mad1k
2026-03-19 08:53:27 -04:00
parent 10435118a9
commit c79f81d9c6
4 changed files with 635 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
from .claude_batch import BatchAnalyzer
from .claude_interactive import InteractiveAnalyzer
from .context_builder import ContextBuilder
+258
View File
@@ -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()
+206
View File
@@ -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]")
+168
View File
@@ -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,
}