Media Framing Analysis Chatbot
Sentra is a Retrieval-Augmented Generation (RAG) chatbot that analyzes and compares media framing in English-language Indonesian news. It runs entirely on local infrastructure: a 3B parameter LLM served by Ollama, local sentence-transformer embeddings, and PostgreSQL. Nothing calls an external API at inference time.
Every answer is evaluated twice: once by custom-trained models ("Model A") and once by rule-based baselines ("Model B"). Both results are shown side by side. That A/B comparison is the point of the project: it makes the cost of a simple heuristic visible instead of assuming the trained model is better.
flowchart LR
Q[User query] --> L{Small talk?}
L -->|greeting| SMALL[Direct reply<br/>no retrieval]
L -->|question| R[Retrieval<br/>MiniLM + cosine]
R -->|max sim < 0.35| REF[Refuse to answer]
R --> G[Generation<br/>qwen2.5:3b via Ollama]
R --> F[Framing<br/>DistilBERT / TF-IDF]
G --> H[Hallucination check<br/>LogReg / keyword overlap]
H --> C[Confidence<br/>RandomForest / heuristic]
F --> OUT[Answer + A/B panel]
C --> OUT
The retrieval gate matters: when the best-matching source scores below threshold, Sentra refuses rather than generating an unsupported answer.
-
Clone the repository and navigate to the project directory.
-
Create and activate a virtual environment:
python -m venv venv # Windows .\venv\Scripts\activate # Linux/Mac source venv/bin/activate
-
Install dependencies:
pip install -r requirements.txt
-
Install Ollama, then build the serving model:
ollama pull qwen2.5:3b ollama create sentra-qwen:3b -f ollama/Modelfile
qwen2.5:3bis a ~1.9 GB multilingual model that runs on CPU. The Modelfile step is not optional: Ollama defaults to a 2048-token context window, and Sentra's prompts (system template + chunks from three outlets + history) exceed that. Ollama truncates silently, which looks like a retrieval bug.ollama/Modelfilebakesnum_ctx 8192into the model so the fix survives however the server is started.To use a different model, edit
ollama/ModelfileandLLM_MODELin.env. -
Configure environment variables: Copy
.env.exampleto.envand adjust:DATABASE_URL=postgresql://user:password@localhost:5432/Sentra1 LLM_BASE_URL=http://localhost:11434/v1 LLM_MODEL=llama3.1
No API key is required for a local endpoint. To use a hosted provider instead, point
LLM_BASE_URLat it (e.g.https://openrouter.ai/api/v1) and setOPENROUTER_API_KEY. -
Populate the database and train the models (the trained artifacts are gitignored, so a fresh clone must regenerate them):
python scripts/tools/refresh_database.py # ingest the article corpus python scripts/train_models.py # Model A: hallucination + confidence python scripts/train_framing_bert.py # Model A: DistilBERT framing
Run the FastAPI server:
python -m uvicorn api.main:app --reloadThe API starts at http://localhost:8000, and the web interface is served at the root URL.
python scripts/debug/verify_ollama.py # is the model server reachable?
python scripts/debug/verify_model_direct.py # does the configured model answer?pytest # 449 unit tests (offline; no server, database or model weights)
pytest -m integration # needs a database; see the warning below
python scripts/uat.py # 36-scenario user acceptance test against a live serverThree layers, each catching what the layer below cannot:
| Layer | Count | Needs | Catches |
|---|---|---|---|
| Unit | 449 | nothing | logic, copy, config invariants, guard behaviour |
| Integration (schema) | 14 | PostgreSQL | that schema.sql executes and provenance columns populate |
| Integration (API) | 10 | running server | that the endpoints answer over HTTP |
| UAT | 36 | server + Ollama | that the product behaves for a user end to end |
The UAT exercises the product the way a user does: greetings, Indonesian and
English questions, in-corpus and out-of-corpus queries, strict mode, multi-turn
memory, and latency, each with explicit pass criteria. It has earned its
keep: it found the all-greetings-detected-as-English bug, the Indonesian-body /
English-headings bug, the "app" substring that hijacked "cabinet
appointments", and one scenario of my own that was simply wrong. Latest run:
36/36 passed (report).
Warning:
tests/test_schema_integration.pytruncates the article tables. It refuses to run unless the database name containstest,_ci,ci_orscratch; point it somewhere disposable withSENTRA_TEST_DATABASE_URL. Running it unguarded emptied a freshly embedded 154-article corpus in under a second, which is why the guard exists and is itself tested.
CI runs the unit tests, the schema/provenance integration job against a real
PostgreSQL service, and a hardcoded-credential scan on every push
(.github/workflows/tests.yml).
- Media Framing Analysis: Compares how different media outlets frame political events using TF-IDF and DistilBERT.
- A/B Model Comparison: Compares "Model A" (Custom ML) against "Model B" (Baseline/Heuristic) for hallucination detection, confidence scoring, and framing.
- Hallucination Detection: Verifies generated answers against retrieved news chunks using Logistic Regression over similarity features.
- Confidence Scoring: Predicts answer reliability using a Random Forest model based on retrieval metrics.
- Retrieval Gating: Refuses to answer when the best-matching source falls below a calibrated similarity threshold, rather than generating an unsupported answer.
- Bilingual: Ask in Indonesian, get an Indonesian answer (headings, disclaimers, and refusals included) from an English corpus. Ask in English and get English.
- Small-talk routing: Greetings are answered directly instead of being pushed through retrieval and refused.
- Self-explaining: Asking "apa fungsi dari Analysis Results?" or "what features does this app have?" returns a plain-language explanation of the panel or feature, no jargon, no retrieval.
The system uses a multi-stage pipeline:
- Intent routing: greetings and thanks are answered directly; only real questions reach retrieval.
- Retrieval:
paraphrase-multilingual-MiniLM-L12-v2embeds the query; chunks are ranked by cosine similarity computed in NumPy. Articles and their embeddings live in PostgreSQL asFLOAT8[]; pgvector is not used. The query is prefixed before embedding (see Query expansion below). - Generation: a local LLM served by Ollama (default
sentra-qwen:3b) generates the response and comparative analysis over an OpenAI-compatible HTTP API. The prompt declares the retrieved outlet names as a closed list, because small models otherwise abbreviate or invent them (observed:ANTARANEWS→ANTV). - Evaluation (Model A):
- Hallucination Detector: Logistic Regression on 5 similarity/overlap features.
- Confidence Scorer: Random Forest Regressor trained on retrieval metrics.
- Framing Analyzer: DistilBERT fine-tuned for media style classification.
- Evaluation (Model B - Baseline):
- Keyword Overlap for fact-checking.
- Deterministic heuristic scoring for confidence.
- Word-frequency / TF-IDF for keyword framing.
Note: The custom ML models (Model A) are trained on synthetic/proof-of-concept data. The architecture is designed to scale with larger, real-world datasets when available.
Generated by python scripts/evaluate_models.py; full report in
evaluation_results/latest_evaluation.md.
Hallucination detection (16 labelled claim/source pairs)
| Metric | Model A (Logistic Regression) | Model B (keyword overlap) |
|---|---|---|
| Accuracy | 75.00% | 56.25% |
| F1 | 80.00% | 69.57% |
Confidence scoring
| Metric | Model A (Random Forest) | Model B (heuristic) |
|---|---|---|
| In-range accuracy | 88.89% | 44.44% |
| Mean absolute error | 0.089 | 0.180 |
Framing keyword extraction (top-15 keywords per outlet, no manual labels)
| Metric | TF-IDF | Word frequency |
|---|---|---|
| Distinctiveness (mean pairwise Jaccard, lower is better) | 17.92% | 26.81% |
| Generic-term rate (share also in corpus top-50, lower is better) | 76.67% | 86.67% |
| Source attribution accuracy | 51.35% | 45.95% |
| Majority-class baseline | 59.46% | 59.46% |
The framing row is a negative result, reported as one. TF-IDF produces
noticeably more distinctive and less generic keyword sets than raw counts, but
neither extractor beats the majority-class baseline at identifying which outlet
wrote a held-out article. Keyword frequency alone does not carry outlet identity.
The fine-tuned DistilBERT classifier reaches 79% accuracy on that same task
(scripts/train_framing_bert.py), which is the argument for using it.
Caveat: the DistilBERT figure comes from its own stratified split over title/content segments, not the article-level split used for the keyword metrics, so treat it as indicative rather than a strict head-to-head.
Model A's hallucination detector and confidence scorer are trained on synthetic data. Their numbers demonstrate that the architecture works end to end; they are not a benchmark against real annotations.
Indonesian questions were being refused. Similarity scores across the whole
corpus looked compressed, so a "News about: " prefix was added to the query
before embedding, on the theory that a paraphrase model wants a sentence rather
than a bare phrase. It measured as a large improvement: distribution separation
went from −0.022 to +0.039, and three false refusals became zero.
That measurement was worthless. The real fault was that every stored embedding
had been produced by a different model than the query embeddings.
EMBEDDING_MODEL had been switched from an English-only model to a multilingual
one without re-ingesting. Both output 384 dimensions, so nothing crashed and no
error was ever logged: retrieval simply compared vectors across two unrelated
embedding spaces. A chunk whose true similarity was 0.606 scored 0.350.
Re-embedding the corpus fixed the root cause. With correct vectors, the prefix gave identical error rates and slightly worse separation, so it was removed. The threshold calibrated against the broken state (0.28) turned out to admit 5 of 8 deliberately unanswerable queries once retrieval worked, and was raised to 0.35.
A fix validated against a broken baseline measures the breakage, not the fix.
Three things came out of it, and they are the reason this section exists:
article_chunks.embedding_modelnow records which model produced every vector, andapi/main.pyrefuses to start quietly on a mismatch.- Every request logs one
[TRACE]line with the similarity spread (max/median/min). All three scores sliding down together is the signature of an embedding mismatch; a low max alone is just a hard query. Diagnosing this without that line took five throwaway scripts. print()logging was reconfigured to line-buffered. Piped to a file, the startup diagnostics were sitting unflushed in an 8 KB buffer, so the guard was running and reporting nothing.
After retrieval was fixed, siapa pemenang pemilu 2024? ("who won the 2024
election?") was still refused. It was tempting to read that as a cross-lingual
retrieval weakness and lower the threshold again.
It was neither. The corpus consisted of aftermath coverage and named the winner exactly once, in passing, inside a single quick-count article. One incidental mention cannot be ranked above 280 other chunks, and no threshold, prompt rule or reranker changes that. The most basic question in the domain was unanswerable because the answer was barely in the data.
The fix was five articles reporting the official result: the KPU declaration, the vote counts, and the inauguration, sourced from ANTARA News and Tempo with URLs checked against the live sites. Retrieval now scores 0.551 on the Indonesian phrasing and the answer names Prabowo Subianto with the official 58.59% share.
tests/test_corpus.py asserts the fix cannot silently regress, including that at
least one article opens by naming the winner. Chunking is positional, so a
fact buried at the end of a long article lands in a later chunk and competes with
the whole corpus again.
The same test file caught two defects that had been in the corpus from the start:
two articles duplicated another's URL (the same source ingested twice, which
double-counted one outlet's vocabulary in the framing comparison), and 67
typographic characters made Indonesia's and quick-count different tokens from
their ASCII spellings, silently splitting TF-IDF term counts.
All stochastic components use fixed seeds (LLM_SEED, random_state=42, torch.manual_seed, seeded train/test splits), and the Model B baselines contain no randomness, so an A/B evaluation run is repeatable given the same corpus.
"The same corpus" is checkable rather than assumed: data/corpus.py fingerprints
the article list (indonesia-election-2024@cecce290d0ff), ingestion stores that
fingerprint on every article row, and startup reports which version the database
holds, with a warning if a partial re-ingest left more than one.
Three places were reporting success they had not earned. Each is now capable of saying "no":
/api/healthreturned a literal{"status": "ok", "models": "loaded"}: healthy before initialisation had run, with the database unreachable, and with Model A missing. It now reportsokordegradedwith the failing checks named, plus the corpus version, chunk count and live session counters.- The A/B sidebar rendered
0/0as green "Strong Alignment", claiming a verification result that had never been computed, and left the previous answer's scores on screen through a refusal. Both now read "Not evaluated". - A dead model server produced
"Connection Error: could not reach localhost:11434"as the answer, then split it into sentences, scored it with the hallucination detector, given a confidence percentage, and written into conversation memory. It now raisesLLMUnavailable, returns the retrieved sources with an empty comparison, and is never remembered.
POST /api/ingestwrites into the corpus the chatbot cites. SetINGEST_API_KEYin.envand send it asX-API-Keyto gate it; unset, the endpoint stays open for local development and startup logs a warning.- CORS is restricted to localhost origins by default (
CORS_ALLOW_ORIGINS). - No secrets in source: CI fails the build if an API key pattern appears in tracked files.
- Error responses no longer echo
str(e)to the client; tracebacks go to the server log. - The destructive half of the integration suite refuses to run against a database whose name is not marked disposable. It emptied the development corpus once.
- Retrieval loads every chunk for a media source and scores it in Python on each request. That is fine for the 154-article corpus here; it is not a design for large collections.
- The corpus is a single news event (the 2024 election and its aftermath), so framing differences between outlets are narrow by construction.
- Conversation memory is in-process: restarting the server clears every session.
- The in-corpus and out-of-corpus similarity distributions overlap by 0.014. Two valid queries score 0.329 and are refused at the calibrated 0.35 threshold; lowering it to admit them leaks 3 of 8 unanswerable queries. No threshold satisfies both, and the UAT does not paper over it.
- Model A's hallucination detector flags most of the Comparative Framing Analysis section as unsupported. That is correct, since the prompt asks the model to infer tone and emphasis, which is not literally in the sources. It does mean the headline support rate mixes factual claims with interpretive ones and reads lower than the factual grounding alone would.
data/election_articles.jsonstores article text ABC News could not be re-verified against: abc.net.au blocks automated access, so the five added result articles come from ANTARA News and Tempo only.
Sentra/
├── api/ # FastAPI routes and server logic
├── chatbot/ # Core RAG engine, LLM client, prompts, conversation memory
├── config/ # Pydantic settings loaded from .env
├── data/ # Article corpus (JSON) + versioning loader
├── database/ # Connection manager and schema
├── evaluation_results/ # Generated A/B evaluation report (committed)
├── ollama/ # Modelfile for the serving model
├── models/ # ML model definitions + trained artifacts (gitignored)
│ ├── baseline/ # Model B (rule-based)
│ ├── confidence/ # Model A confidence scorer
│ ├── framing/ # TF-IDF + DistilBERT analyzers, evaluation metrics
│ ├── hallucination/ # Model A hallucination detector
│ └── framing_bert/ # Fine-tuned DistilBERT weights (generated)
├── pipeline/ # Preprocessing, embeddings, ingestion
├── rag/ # Vector retrieval logic
├── scraper/ # News scrapers (ANTARA, Tempo, ABC News)
├── scripts/ # Training, evaluation, and debug utilities
├── tests/ # pytest suite (unit + integration)
├── utils/ # Per-request tracing
├── web/ # Frontend static files (HTML/JS/CSS)
├── requirements.txt # Pinned Python dependencies
└── .env.example # Environment template
The corpus covers the 2024 Indonesian presidential election and its aftermath:
154 English-language articles in data/election_articles.json:
| Source | Articles | Perspective |
|---|---|---|
| ANTARA News (en.antaranews.com) | 89 | Indonesian national news agency |
| Tempo English (en.tempo.co) | 34 | Indonesian investigative journalism |
| ABC News (abc.net.au) | 31 | Australian international perspective |
Rebuild the database from it with python scripts/tools/refresh_database.py,
which re-embeds every chunk and verifies the vectors it wrote.
Source identifiers (antaranews, tempo, abc_news) are defined once in
settings.SUPPORTED_MEDIA and reused by ingestion, retrieval, and training.
- Fork the project.
- Create your feature branch.
- Commit your changes (
pytestmust pass). - Push to the branch.
- Open a Pull Request.
Prototype. The project demonstrates a working proof-of-concept for media framing analysis in Indonesian political news. Training data is currently synthetic; the architecture supports expansion to larger datasets.