Skip to content

Add azure_doc_qa example: multi-agent doc QA with eval - #86

Merged
jakepresent merged 36 commits into
mainfrom
yemingtang/azure-doc-qa-example
May 26, 2026
Merged

Add azure_doc_qa example: multi-agent doc QA with eval#86
jakepresent merged 36 commits into
mainfrom
yemingtang/azure-doc-qa-example

Conversation

@tangym

@tangym tangym commented May 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds examples/azure_doc_qa/ — a complete multi-agent document Q&A example demonstrating eval-driven development with p2m. The example walks through 7 rounds of iterative improvement, taking a LangGraph agent from ~20% to 82% pass rate using systematic evaluation.

What's included

  • Multi-agent LangGraph system (agent.py) with triage → product_docs / internal_docs / escalation routing
  • Iterative tool-call loop (_run_agent_loop) — up to 3 rounds of tool calls per specialist node
  • Identity verification — clearance-based access control (public / internal / confidential)
  • 7 mock tool implementations (mock_tools.py): doc search, content retrieval, service status, incident reports, internal KB search, architecture diagrams, caller identity verification
  • MCP client adapter (mcp_tools.py) bridging mock tools into the LangGraph ToolNode interface
  • 8 reference documents (docs/) — both external and internal — providing the agent's knowledge base
  • Eval configuration (eval_config.yaml) with 9 behavior categories, 56 test cases (40 prompt + 16 scenario), and 9 judge dimensions
  • OpenTelemetry trace wrapper (auto_trace.py) as the p2m callable entry point
  • Improvement Journey (IMPROVEMENT_JOURNEY.md) — detailed walkthrough of all 7 rounds
  • Interactive Chat UI (chat_app.py) — Chainlit-based browser interface for testing the agent

Architecture

User Query → Triage Agent → ProductDocsAgent (public docs, synthesis)
                           → InternalDocsAgent (identity verify → search → answer)
                           → Escalation (human handoff)

Each specialist runs an iterative tool-call loop (max 3 rounds), enabling multi-step workflows like: verify identity → search internal docs → retrieve full text → synthesize answer.

Chat UI (Chainlit)

An interactive chat interface lets you talk to the agent in a browser and visualize triage routing, tool calls, and tool results as collapsible steps.

pip install chainlit
USE_MOCK_TOOLS=1 chainlit run examples/azure_doc_qa/chat_app.py
# → http://localhost:8000

Features:

  • Real-time visualization of triage routing decisions
  • Collapsible tool-call steps showing inputs and results for each tool invocation
  • User Role toggle (gear icon → Settings) to switch between external (default) and internal_engineer — tests information barrier enforcement without code changes
  • Mock mode support (USE_MOCK_TOOLS=1) for fully offline demos
  • Streaming responses with step-by-step agent reasoning

Eval-Driven Development Journey

Round Pass Rate Key Changes
Baseline ~20% Initial agent with routing JSON leaks
Round 1 61% Fixed routing leaks, synthesis, response extraction
Round 2 71% Conversation memory, specialist escalation, prompt tuning
Round 3 71% Content boundaries, grounding guardrails, mixed-domain handling
Round 4 67.9% Audience-aware sharing, synthesis requirement (expanded 56-case suite)
Round 5 67.9% Baseline with expanded config + user_persona dimension
Round 6 62.5% Tighter rubrics exposed single-round tool-call limitation
Round 7 82.1% Iterative tool-call loop + prompt restructuring

Judge Dimensions (9)

policy_violation, overrefusal, hallucination, attribution_error, boundary_violation, prompt_injection, workflow_violation, escalation_judgment, wrong_tool

How to run

# Eval pipeline
USE_MOCK_TOOLS=1 p2m run --config examples/azure_doc_qa/eval_config.yaml

# Chat UI
pip install chainlit
USE_MOCK_TOOLS=1 chainlit run examples/azure_doc_qa/chat_app.py

tangym added 7 commits May 22, 2026 21:53
Replace generic 'Missing or invalid' errors in _kind_and_test_case_id
with a _raise_schema_hint helper that shows:
- which field is missing and what was expected
- the actual keys present in the row
- a concrete 'rm -rf <run_dir>' command to delete stale artifacts
- instruction to re-run the pipeline

This helps users who hit cached artifacts from an older format
understand the problem and fix it without needing to know internal
schema history.
Add task cancellation to _bounded_loop_teardown() between
shutdown_asyncgens() and executor.shutdown(). Without this,
litellm's LoggingWorker._worker_loop coroutine stays pending
when loop.close() is called, producing noisy 'Task was destroyed
but it is pending'@ 2>&1 tracebacks at pipeline exit.

This mirrors what asyncio.run() does internally: enumerate all
remaining tasks, cancel them, then gather() to let cancellations
propagate before closing the loop.
Scaffold the example directory structure with __init__.py and
8 reference documents (3 external, 5 internal) that the multi-agent
system uses as its knowledge base for answering Azure AI questions.
Six mock MCP tools (search_docs, get_doc_content, check_service_status,
get_incident_report, search_internal_kb, get_architecture_diagram) that
simulate real tool backends for deterministic evaluation.
Three-node LangGraph agent (router → doc_specialist → internal_kb)
with an MCP client adapter that bridges mock tools into the LangGraph
ToolNode interface.
9 behavior categories, 25 test cases, and 7 judge dimensions covering
factual accuracy, tool usage, confidentiality handling, multi-hop
reasoning, and cross-reference synthesis.
auto_trace.py provides the p2m callable entry point with OpenTelemetry
instrumentation. README.md documents the example architecture, setup
instructions, and how to run the eval.
@tangym
tangym force-pushed the yemingtang/azure-doc-qa-example branch from 3a74a57 to acd2c0b Compare May 23, 2026 00:39
tangym added 22 commits May 23, 2026 01:34
- Set all temperature values to 1.0 (gpt-5 family only supports temperature=1)
- Remove unsupported test_case_count field from test_set config
- Use agent:chat_sync directly instead of auto_trace:chat_sync (avoids
  phoenix dependency); remove trace config block
The existing _FilteredStderr wrapper on sys.stderr was ineffective because
the logging console handler writes to sys.__stderr__ (the unwrapped
original), bypassing the filter entirely. Async-cleanup tracebacks from
httpx/LangGraph/litellm reach users through three separate channels:

1. Python logging (asyncio logger) — 'Task exception was never retrieved'
   and 'Task was destroyed but it is pending!' go through
   logging.getLogger('asyncio').error() → root handlers → sys.__stderr__.
   Fix: add _AsyncCleanupLoggingFilter to root logger handlers and the
   asyncio logger.

2. sys.unraisablehook — 'Exception ignored in: ...' messages from __del__
   methods that raise RuntimeError('Event loop is closed').
   Fix: install a custom unraisablehook that suppresses these.

3. Direct stderr writes — kept as last resort via _FilteredStderr.

Verified with full pipeline run: zero noise lines in output.
Restore auto_trace:chat_sync callable and trace backend configuration
that was accidentally removed in the temperature fix commit.
When inference is re-run (forced or config hash mismatch), the old
scores.jsonl and .judge_config_hash were left behind. The viewer
builder then crashed with ViewerReadModelBuildError trying to look
up old test_case_ids in the new inference data.

Add _remove_stale_judge_artifacts() to delete scores.jsonl and
.judge_config_hash whenever inference_set.jsonl is discarded.
Instead of crashing with ViewerReadModelBuildError when a score row
references a test_case_id absent from inference_set.jsonl, log a
warning and fall back to transcript_only mode. This is the same
behavior as when scores.jsonl doesn't exist at all.
After generating test_set.jsonl, compare the set of behavior
categories covered by generated test cases against the full
taxonomy. If any categories are missing, log a WARNING with
the missing category names and the minimum sample_size needed
to guarantee full coverage.
triage() was adding its LLM response (a raw JSON routing object like
{"route":"product_documentation","reason":"..."}) as an AIMessage
to the graph state. This polluted the message stream: if the specialist
node's synthesis produced an empty-content AIMessage, the reverse scan
in chat() fell back to this routing JSON as the agent's final answer.

Return only {"route": route} from triage — the routing decision is
metadata for the conditional edge, not a user-facing message.
The specialist nodes (product_docs, internal_docs, escalation) all used
the same tool-bound LLM instance for both the initial tool-calling
invocation AND the followup synthesis call. This meant the synthesis
call could produce another tool_call response with empty .content
instead of a natural-language answer.

Use _get_llm() (un-bound) for the synthesis step so the model is
forced to produce text content rather than tool calls.
Defense-in-depth: when scanning messages in reverse for the final
answer, skip any AIMessage whose content parses as a JSON object
containing a 'route' key. This catches edge cases where routing
metadata might still appear in the message stream.
Accept optional history parameter from p2m's multi-turn inference loop.
Convert p2m history dicts to LangChain messages and prepend them to the
graph invocation, giving the agent context of prior turns.

This fixes the 14 multi-turn failures where the agent could not see
previous messages and repeatedly escalated instead of building on
prior retrieval results.
…al content

Distinguish between general INTERNAL docs (migration runbooks, known issues,
architecture) and explicitly CONFIDENTIAL docs (roadmap, pricing).

The previous prompt treated ALL internal content as unshareable, causing
the internal docs agent to refuse legitimate engineering questions. Now
only docs explicitly marked CONFIDENTIAL or DO NOT SHARE are restricted.
…lation

Narrow escalation criteria to explicit complaints, bug reports, and
user requests for human help. Add guidance to route uncertain queries
to a specialist first — specialists can escalate later if they cannot
answer from their documents.

Previously, 'questions you cannot confidently classify' caused the
triage agent to escalate ambiguous queries that a specialist could
have answered.
Add escalate_to_human to product_docs and internal_docs tool lists so
specialists can escalate AFTER attempting retrieval, rather than the
triage agent pre-routing to the escalation node.

Prompts explicitly instruct specialists to only escalate when the user
asks for a human or files a complaint — never just because they lack
information.
…evelopment

Walk through two rounds of iterative agent improvement using p2m:
- Round 1: diagnosed routing JSON leak from judge justifications, 3 fixes, ~20% → 61%
- Round 2: categorized 22 remaining failures into overrefusal + escalation patterns, 4 fixes

Demonstrates the eval → diagnose → fix → re-eval loop for other developers.
Tighten INTERNAL_DOCS_PROMPT Rules 2-3 to treat INTERNAL documents
the same as CONFIDENTIAL — the agent may acknowledge a document
exists and describe its topic, but must not reproduce procedures,
steps, or configurations from it. If the user needs specifics,
escalate to the owning team.

Fix 5 (Round 2) was too permissive: it shared migration runbooks
freely, but the eval's boundary_violation rubric treats ALL INTERNAL
content as a leak. This caused 5 of 8 Round 3 failures.
Add Rule 8 to INTERNAL_DOCS_PROMPT: when a query spans both public
and internal docs, handle only the internal portion and redirect
the user to public docs for the rest. Previously, mixed queries
routed to internal_engineering caused the specialist to answer
only from internal tools, missing the public portion entirely.
Add Rule 9 to INTERNAL_DOCS_PROMPT: when a search returns only
titles or snippets (not full text), do not fabricate the missing
content. Three Round 3 failures involved the agent inventing
detailed procedures (rollback sequences, cutover step lists)
that were not present in the retrieved tool output.
Update escalation rules in PRODUCT_DOCS_PROMPT and INTERNAL_DOCS_PROMPT
to explicitly prevent escalation of simple follow-up questions or
requests to rephrase already-discussed content. Also allow INTERNAL
specialist to escalate when the user needs restricted content access.

Addresses test_case_000024 where the agent unnecessarily escalated
a simple one-sentence summary request.
Document the third eval-fix cycle: 20/28 pass (71.4%) with failures
in content leakage (5), mixed-domain routing (2), hallucination (3),
and unnecessary escalation (1). Four fixes applied: tighten INTERNAL
content rules, mixed-domain handling, grounding guardrail, and
refined escalation guidance. Key insight: the eval rubric defines
the contract — prompts must conform to it.
Replace blanket 'do NOT reproduce content' rule with audience-aware
policy: INTERNAL docs are sharable with internal engineers, only
CONFIDENTIAL docs require escalation.

Add public doc tools (knowledge_base_retrieve, microsoft_docs_search)
to the internal agent so it can answer mixed-domain questions directly
instead of telling users to ask elsewhere.
tangym added 7 commits May 25, 2026 22:47
…docs agent

Add Rule 9 requiring the product docs specialist to synthesize a
direct answer from retrieved documents instead of just acknowledging
a document exists or telling users to read it themselves.
Add identity verification tool that returns verified_internal clearance
with access to public and internal document classifications.
Replace single-round tool invocation with _run_agent_loop() that supports
up to 3 rounds of tool calls per specialist node. This allows the natural
workflow: verify identity → search → retrieve → answer.

Restructure INTERNAL_DOCS_PROMPT with explicit 3-step WORKFLOW section and
ACCESS RULES BY CLEARANCE table. Add verify_caller_identity to internal
tools. Add Rule 11 preventing the agent from asking users for doc links.
Add user_persona dimension (internal_engineer / external_customer) to
test_set generation. Update boundary_violation rubric for classification-
based access with identity verification. Update context to describe
verify_caller_identity tool. Increase sample sizes to 40 prompt + 16
scenario cases.
…README

Document Rounds 4-7 in IMPROVEMENT_JOURNEY.md covering audience-aware
sharing, identity verification, eval rubric tightening, and the iterative
tool-call loop that brought pass rate from 67.9% to 82.1%.

Update README with current 9 judge dimensions, 56 test cases, tool-call
loop architecture, and improvement journey summary.
The test expected the worker thread to be running (and thus hang the
executor shutdown), but on a busy CI runner the 10ms asyncio.sleep
wasn't enough for the OS to schedule the new thread. This let
executor.shutdown(cancel_futures=True) cancel the not-yet-started
future, completing cleanup instantly without the timeout warning.

Use a threading.Event to synchronize: the coroutine now waits until
the worker thread has actually started executing before returning,
guaranteeing the precondition the test relies on.
The _AsyncCleanupLoggingFilter suppresses log records containing
'Event loop is closed'. The teardown warning message itself contained
that exact substring as advisory text, causing the filter to
inadvertently suppress the warning when the filter was installed by
a prior run_pipeline() call. Remove the literal substring so the
warning passes through the filter correctly.
@jakepresent
jakepresent merged commit 81412a6 into main May 26, 2026
3 checks passed
changliu2 added a commit that referenced this pull request May 28, 2026
…esults

- Restyle both incident_triage_simple and incident_triage_agent READMEs to match the PR #86 azure_doc_qa customer-facing format: top headline, what-this-demonstrates bullets, architecture diagram, quick-start, env-var table, judge-dimension list, expected-output file list.

- Strip every reference to Agent Shield / ACS / agent_guarded.py / guardrails.yaml from both folders. Keep agent.py only.

- Drop trade-off chart artifact + script reference. Drop all committed result snapshots (results are highly non-deterministic; the README now describes what artifacts to expect, not specific numbers).

- Consolidate to a single eval_config.yaml per folder. Drop variant configs (baseline / naive_prompt / guarded / GEPA).

Rebased onto current main (post #110 multi-preset judge dimensions).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants