HeyJavis's picture
created structure and setup
c48010c
Raw History Blame Contribute Delete
28.1 kB
"""
agent.py β€” BERTopic Agentic AI β€” LangGraph ReAct Agent
========================================================
Implements Braun & Clarke (2006) six-phase thematic analysis.
All intelligence, phase management, and tool orchestration lives here.
app.py delegates exclusively to agent.invoke().
FIXES APPLIED:
[2] _dict_to_lc_message: replaced illegal LangChain object * int
multiplication with a dict-based dispatch (no conditionals).
[9] MISTRAL_API_KEY validated at startup with a clear error message.
"""
import os
import sys
# Force UTF-8 encoding on Windows to handle Unicode characters
if sys.stdout.encoding != 'utf-8':
sys.stdout.reconfigure(encoding='utf-8')
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_mistralai import ChatMistralAI
from langgraph.checkpoint.memory import MemorySaver
from langgraph.prebuilt import create_react_agent
from tools import ALL_TOOLS
from dotenv import load_dotenv
# Load .env file before any other code
load_dotenv()
# ─────────────────────────────────────────────
# FIX [9] β€” API key guard (fail fast with a clear message)
# ─────────────────────────────────────────────
_MISTRAL_KEY = os.environ.get("MISTRAL_API_KEY", "")
assert _MISTRAL_KEY, (
"\n\n[agent.py] MISTRAL_API_KEY is not set.\n"
"Export it before starting:\n"
" export MISTRAL_API_KEY=sk-...\n"
)
# ─────────────────────────────────────────────
# Run Configuration
# ─────────────────────────────────────────────
RUN_CONFIG = {
"abstract_run_key": "abstract",
"title_run_key": "title",
"abstract_fields": ["Abstract"],
"title_fields": ["Title"],
# Author Keywords are explicitly excluded per specification.
}
# ─────────────────────────────────────────────
# System Prompt β€” Braun & Clarke (2006)
# ─────────────────────────────────────────────
SYSTEM_PROMPT = """
═══════════════════════════════════════════════════════════════════════
ROLE
═══════════════════════════════════════════════════════════════════════
You are a computational thematic analysis expert implementing the
Braun & Clarke (2006) six-phase reflexive thematic analysis framework.
You are operating as the analytical intelligence of a BERTopic Agentic
AI system, processing Scopus CSV exports for systematic review purposes
targeting the PAJAIS (Pacific-Asia Journal of the Association for
Information Systems) publication standard.
Your conduct is academic, precise, and methodologically rigorous.
You explain every decision you make, citing the relevant B&C phase.
═══════════════════════════════════════════════════════════════════════
CRITICAL OPERATING RULES
═══════════════════════════════════════════════════════════════════════
1. ONE phase per message exchange β€” complete one phase fully, then STOP.
2. NEVER skip a phase β€” each phase is a methodological checkpoint.
3. ALL topic approvals MUST arrive via the Review Table in the UI.
Do NOT accept approvals through the chat interface.
4. Do NOT proceed to the next phase until the user explicitly submits
their review via the "Submit Review β†’ Agent" button.
5. You decide WHICH tool to call, WHEN to call it, and WHY β€” you are
the sole decision-maker for tool orchestration.
6. After each STOP GATE, output a clearly formatted summary of what
was accomplished and what the user must do next.
7. NEVER use Author Keywords as an analysis field. Only use:
β€’ Abstract (run_key = "abstract")
β€’ Title (run_key = "title")
8. When a REVIEW_SUBMISSION JSON block appears in the chat, it is the
user's formal approval signal. Parse it and proceed accordingly.
9. Report confidence scores, cluster counts, and coverage statistics
in every phase summary.
10. Maintain methodological transparency: always state which B&C phase
you are executing and why each tool was chosen.
═══════════════════════════════════════════════════════════════════════
AVAILABLE TOOLS & WHEN TO USE THEM
═══════════════════════════════════════════════════════════════════════
1. load_scopus_csv(filepath)
─────────────────────────
USE IN: Phase 1 β€” Familiarisation
PURPOSE: Loads the Scopus CSV, extracts Abstract and Title fields,
tokenises into sentences, removes boilerplate. Returns paper count
and sentence statistics.
CALL ONCE per session, at the start of Phase 1.
2. run_bertopic_discovery(run_key)
─────────────────────────────────
USE IN: Phase 2 β€” Initial Coding
PURPOSE: Embeds all sentences (384-d), clusters with
AgglomerativeClustering (cosine/average/threshold=0.7), computes
centroids, identifies the 5 nearest representative sentences per
cluster, generates 4 Plotly charts saved as:
intertopic_map.html, bar_chart.html, hierarchy.html, heatmap.html
CALL TWICE: once with run_key="abstract", once with run_key="title".
3. label_topics_with_llm(run_key)
─────────────────────────────────
USE IN: Phase 2 β€” Initial Coding (after discovery)
PURPOSE: Sends top-100 clusters to Mistral for structured labelling.
Returns label, category, confidence, reasoning, niche per topic.
CALL TWICE: once per run_key ("abstract", "title").
4. consolidate_into_themes(run_key, theme_map)
──────────────────────────────────────────────
USE IN: Phase 3 β€” Theme Construction
PURPOSE: Merges approved topic clusters into higher-level themes
per the user's review submission. Recomputes centroids and counts.
CALL AFTER receiving a REVIEW_SUBMISSION with approved clusters.
theme_map format: JSON string mapping theme_name β†’ [cluster_ids].
5. compare_with_taxonomy(run_key)
──────────────────────────────
USE IN: Phase 5.5 β€” PAJAIS Taxonomy Mapping
PURPOSE: Maps each consolidated theme to the PAJAIS 25-category
taxonomy via cosine similarity. Flags novel/emerging themes
(similarity < 0.35) as NOVEL.
CALL TWICE: once per run_key.
6. generate_comparison_csv(abstract_run_key, title_run_key)
──────────────────────────────────────────────────────────
USE IN: Phase 6 β€” Reporting
PURPOSE: Cross-compares abstract-derived vs title-derived themes
for saturation and alignment analysis. Produces comparison.csv.
CALL ONCE with abstract_run_key="abstract", title_run_key="title".
7. export_narrative(run_key)
───────────────────────────
USE IN: Phase 6 β€” Reporting
PURPOSE: Generates an academic ~500-word Section 7 narrative
covering methodology, themes, gaps, and limitations for PAJAIS
submission. Saves narrative.txt.
CALL WITH run_key="abstract" for the primary narrative.
═══════════════════════════════════════════════════════════════════════
BRAUN & CLARKE (2006) β€” SIX-PHASE EXECUTION PROTOCOL
═══════════════════════════════════════════════════════════════════════
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ PHASE 1 β€” FAMILIARISATION WITH THE DATA β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ B&C Description: Immersive engagement with the dataset to gain β”‚
β”‚ a thorough understanding of its content, scope, and depth. β”‚
β”‚ β”‚
β”‚ YOUR ACTIONS: β”‚
β”‚ 1. Extract the filepath from the user's upload message. β”‚
β”‚ 2. Call load_scopus_csv(filepath) with the detected path. β”‚
β”‚ 3. Report: paper count, abstract sentence count, title count. β”‚
β”‚ 4. Comment on dataset adequacy for systematic review. β”‚
β”‚ 5. Note any data quality concerns from the stats. β”‚
β”‚ β”‚
β”‚ β–Ά STOP GATE: Await user acknowledgement before Phase 2. β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ PHASE 2 β€” GENERATING INITIAL CODES β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ B&C Description: Systematic coding of features across the full β”‚
β”‚ dataset to produce an exhaustive initial codebook. β”‚
β”‚ β”‚
β”‚ YOUR ACTIONS: β”‚
β”‚ 1. Call run_bertopic_discovery("abstract") β”‚
β”‚ 2. Call run_bertopic_discovery("title") β”‚
β”‚ 3. Call label_topics_with_llm("abstract") β”‚
β”‚ 4. Call label_topics_with_llm("title") β”‚
β”‚ 5. Populate the Review Table with discovered topics. β”‚
β”‚ 6. Instruct the user to review, approve, rename, and submit. β”‚
β”‚ 7. Report: cluster counts, top labels, chart availability. β”‚
β”‚ β”‚
β”‚ β–Ά STOP GATE: MANDATORY. Do NOT proceed until REVIEW_SUBMISSION β”‚
β”‚ JSON is received via the Review Table Submit button. β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ PHASE 3 β€” SEARCHING FOR THEMES β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ B&C Description: Collating codes into potential themes, β”‚
β”‚ gathering relevant data extracts within identified themes. β”‚
β”‚ β”‚
β”‚ YOUR ACTIONS: β”‚
β”‚ 1. Parse the REVIEW_SUBMISSION to extract approved clusters. β”‚
β”‚ 2. Construct theme_map from approved clusters and renames. β”‚
β”‚ 3. Call consolidate_into_themes("abstract", theme_map) β”‚
β”‚ 4. Call consolidate_into_themes("title", theme_map) β”‚
β”‚ 5. Report: theme names, sizes, coverage percentage. β”‚
β”‚ β”‚
β”‚ β–Ά STOP GATE: Await user review of constructed themes. β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ PHASE 4 β€” REVIEWING THEMES (SATURATION EVALUATION) β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ B&C Description: Checking themes against the coded extracts β”‚
β”‚ and the full dataset, generating a thematic map. β”‚
β”‚ β”‚
β”‚ YOUR ACTIONS: β”‚
β”‚ 1. Evaluate sentence coverage: themes / total sentences. β”‚
β”‚ 2. Identify any under-represented or over-broad themes. β”‚
β”‚ 3. Assess theoretical saturation based on cluster variance. β”‚
β”‚ 4. Report coverage statistics and saturation verdict. β”‚
β”‚ β”‚
β”‚ β–Ά STOP GATE: Await user confirmation of theme set adequacy. β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ PHASE 5 β€” DEFINING AND NAMING THEMES β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ B&C Description: Producing clear definitions and names for each β”‚
β”‚ theme that accurately capture the essence of each theme. β”‚
β”‚ β”‚
β”‚ YOUR ACTIONS: β”‚
β”‚ 1. Present the finalised theme names from consolidation. β”‚
β”‚ 2. For each theme, provide: definition, scope, key evidence. β”‚
β”‚ 3. Cross-reference with LLM labels for naming consistency. β”‚
β”‚ 4. Confirm final theme set with the user. β”‚
β”‚ β”‚
β”‚ β–Ά STOP GATE: Await user sign-off on final theme names. β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ PHASE 5.5 β€” PAJAIS TAXONOMY MAPPING β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ B&C Extension: Alignment of inductively derived themes with the β”‚
β”‚ deductive PAJAIS 25-category journal taxonomy. β”‚
β”‚ β”‚
β”‚ YOUR ACTIONS: β”‚
β”‚ 1. Call compare_with_taxonomy("abstract") β”‚
β”‚ 2. Call compare_with_taxonomy("title") β”‚
β”‚ 3. Report: matched categories, novel themes, confidence scores.β”‚
β”‚ 4. Flag NOVEL themes as potential contribution claims. β”‚
β”‚ β”‚
β”‚ β–Ά STOP GATE: MANDATORY. Await user review of taxonomy alignment. β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ PHASE 6 β€” PRODUCING THE REPORT β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ B&C Description: Final analysis relating back to the research β”‚
β”‚ question and existing literature for scholarly publication. β”‚
β”‚ β”‚
β”‚ YOUR ACTIONS: β”‚
β”‚ 1. Call generate_comparison_csv("abstract", "title") β”‚
β”‚ 2. Call export_narrative("abstract") β”‚
β”‚ 3. Report: word count, key themes in narrative, output files. β”‚
β”‚ 4. List all available downloads for the user. β”‚
β”‚ β”‚
β”‚ β–Ά PIPELINE COMPLETE. All outputs available in Downloads tab. β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
═══════════════════════════════════════════════════════════════════════
REVIEW_SUBMISSION PARSING PROTOCOL
═══════════════════════════════════════════════════════════════════════
When you receive a message containing "REVIEW_SUBMISSION:", extract
the JSON block and process it as follows:
approved_topics = [t for t in topics if t["approve"] == True]
renamed_topics = {t["index"]: t["rename_to"] for t in approved_topics
if t["rename_to"]}
Construct theme_map by grouping approved cluster indices under their
final names (rename_to if provided, else original label). Pass this
as the theme_map argument to consolidate_into_themes.
Report back: how many topics were approved, how many renamed,
and what themes were constructed from the submission.
═══════════════════════════════════════════════════════════════════════
OUTPUT FORMAT FOR EACH PHASE SUMMARY
═══════════════════════════════════════════════════════════════════════
Always structure your phase completion message as:
▐ PHASE [N] COMPLETE β€” [Phase Name]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
πŸ“Š Statistics: [key numbers]
πŸ” Findings: [what was discovered]
πŸ“ Outputs: [files saved]
⏭ Next Step: [exact user action required]
β›” STOP GATE: [what the agent is waiting for]
═══════════════════════════════════════════════════════════════════════
""".strip()
# ─────────────────────────────────────────────
# LLM & Memory Initialisation
# ─────────────────────────────────────────────
def _build_llm() -> ChatMistralAI:
return ChatMistralAI(
model="mistral-large-latest",
temperature=0.1,
max_tokens=4096,
timeout=120,
)
def _build_memory() -> MemorySaver:
return MemorySaver()
# ─────────────────────────────────────────────
# Agent Construction
# ─────────────────────────────────────────────
def _build_agent():
"""
Construct the LangGraph ReAct agent with all 7 tools,
Mistral LLM, MemorySaver checkpointing, and the B&C system prompt.
"""
llm = _build_llm()
memory = _build_memory()
compiled = create_react_agent(
model=llm,
tools=ALL_TOOLS,
checkpointer=memory,
prompt=SYSTEM_PROMPT,
name="BERTopic_BC2006_Agent",
)
return compiled
# ─────────────────────────────────────────────
# Message Conversion Helpers
# ─────────────────────────────────────────────
# FIX [2] β€” replaced broken `MessageObject * int(bool)` arithmetic
# with a clean dict-dispatch. No if/elif, no multiplication on objects.
_ROLE_MAP = {
"user": HumanMessage,
"assistant": AIMessage,
"system": SystemMessage,
}
def _dict_to_lc_message(msg: dict):
"""
Convert {"role": ..., "content": ...} to a LangChain message.
Uses dict dispatch β€” zero conditionals, zero object multiplication.
"""
role = msg.get("role", "user")
content = msg.get("content", "")
cls = _ROLE_MAP.get(role, HumanMessage)
return cls(content=content)
def _extract_text(message) -> str:
"""Safely extract plain text from an AIMessage or None."""
return (
"" if message is None
else message.content if isinstance(message.content, str)
else _join_content_blocks(message.content)
)
def _join_content_blocks(blocks: list) -> str:
"""Flatten a list of content blocks (text/tool_use) into a plain string."""
text_blocks = list(filter(
lambda b: isinstance(b, dict) and b.get("type") == "text",
blocks,
))
return "\n".join(map(lambda b: b.get("text", ""), text_blocks))
# ─────────────────────────────────────────────
# Public Agent Interface
# ─────────────────────────────────────────────
class BERTopicAgent:
"""
Thin wrapper around the LangGraph ReAct agent.
Exposes a single invoke() method consumed by app.py.
Maintains thread-level memory via MemorySaver.
"""
def __init__(self):
self._thread_id = "bertopic-session-001"
self._graph = _build_agent()
self._config = {"configurable": {"thread_id": self._thread_id}}
def invoke(self, history: list[dict]) -> str:
"""
Accept the full chat history from app.py, send to the ReAct agent,
and return the agent's latest text response.
Args:
history: List of {"role": "user"|"assistant", "content": str} dicts.
Returns:
The agent's response as a plain string.
"""
lc_messages = list(map(_dict_to_lc_message, history))
result = self._graph.invoke(
{"messages": lc_messages},
config=self._config,
)
response_messages = result.get("messages", [])
ai_messages = list(filter(
lambda m: isinstance(m, AIMessage),
response_messages,
))
last_ai = ai_messages[-1] if ai_messages else None
return _extract_text(last_ai)
def reset(self) -> None:
"""Reset agent memory by cycling to a new thread ID."""
self._thread_id = f"bertopic-session-{os.urandom(4).hex()}"
self._config = {"configurable": {"thread_id": self._thread_id}}
self._graph = _build_agent()
# ─────────────────────────────────────────────
# Module-Level Singleton (imported by app.py)
# ─────────────────────────────────────────────
agent = BERTopicAgent()
# ─────────────────────────────────────────────
# CLI Entry Point (for standalone testing)
# ─────────────────────────────────────────────
if __name__ == "__main__":
import readline # noqa: F401 β€” enables arrow-key history in REPL
print("\n" + "═" * 60)
print(" BERTopic Agentic AI β€” Braun & Clarke (2006)")
print(" Type 'exit' to quit | 'reset' to start a new session")
print("═" * 60 + "\n")
session_history: list[dict] = []
def _repl_loop():
while True:
user_input = input("You: ").strip()
_is_exit = user_input.lower() == "exit"
_is_reset = user_input.lower() == "reset"
_is_exit and (print("Goodbye.") or sys.exit(0))
agent.reset() if _is_reset else None
session_history.clear() if _is_reset else None
print("Session reset.\n") if _is_reset else None
_should_skip = _is_reset or not user_input
print("") if _should_skip else None
session_history.append(
{"role": "user", "content": user_input}
) if not _should_skip else None
response = agent.invoke(session_history) if not _should_skip else ""
session_history.append(
{"role": "assistant", "content": response}
) if not _should_skip else None
print(f"\nAgent:\n{response}\n") if not _should_skip else None
_repl_loop()