DEV Community

Cover image for Building a Self-Verifying FTIR Agent with Qwen Function Calling
bob lee
bob lee

Posted on

Building a Self-Verifying FTIR Agent with Qwen Function Calling

This post walks through the architecture of ChemSpectra Agent, an AI autopilot for FTIR spectral interpretation built during the Qwen Cloud Hackathon. The agent uses Qwen 3.7-Max (Alibaba Cloud DashScope) with Function Calling to orchestrate six analytical tools, then passes results through a deterministic rule engine that produces auditable, reproducible verdicts.

The core design constraint: the LLM chooses which tool to call, but Python rules — not the model — decide the verdict. The system runs against a production library of 130,000+ reference spectra.

The problem: near-tied candidates

A spectral library search returns ranked matches. When the top score is 0.95 and the second is 0.70, the answer is obvious. But in real-world hard cases, the gap between #1 and #2 can be 0.004 — with fifteen candidates scoring above 0.90.

A ranked list does not answer the analyst's question. They need to know: is the evidence strong enough to act on the top candidate, and if not, what should I do next?

Architecture overview

User uploads spectrum
    ↓
Qwen 3.7-Max (ReAct loop, Function Calling)
    ↓ routes to one of six tools per intent:
    ├── search_library       → FTIR.fun REST API (130K+ spectra)
    ├── assess_direction     → local deterministic arbitration
    ├── cross_validate       → local deterministic chemistry rules
    ├── explain_peaks        → FTIR.fun peak knowledge graph
    ├── check_spectrum_quality → local file parser + quality checks
    └── search_public_cases  → FTIR.fun MCP (shared analysis cases)
    ↓
Host evidence gate (ensures required tools were called)
    ↓
Deterministic verdict: GREEN / YELLOW / RED
    ↓
Human confirmation checkpoint
    ↓
Report
Enter fullscreen mode Exit fullscreen mode

No LangChain, no agent framework. The ReAct loop is direct DashScope API calls with enable_thinking=True.

Tool routing by intent

Qwen's Function Calling handles intent routing naturally. The same agent session supports different question types:

  • "What material is this?" → search_library
  • "What does the peak at 1715 cm⁻¹ mean?" → explain_peaks
  • "Is this spectrum usable?" → check_spectrum_quality

After the initial library search, Qwen decides whether to call assess_direction and cross_validate based on the results. If the top score is strong and the gap is wide, it may skip arbitration. If candidates are close, it calls the direction tool.

But there's a safety net.

The host evidence gate

The agent can't skip critical evidence steps. Before the model generates a final synthesis, the host checks whether required tools were called:

required = {"search_library"}
if near_tied_candidates:
    required |= {"assess_direction", "cross_validate"}

called = {log["tool"] for log in session.tool_calls_log}
missing = required - called

for tool_name in missing:
    result = execute_tool(tool_name, session)
    feed_result_back_to_model(tool_name, result, session)
Enter fullscreen mode Exit fullscreen mode

If Qwen tries to synthesize without running arbitration on near-tied results, the host runs the missing tool and feeds the result back. The model then continues with complete evidence. This is not a prompt instruction (which can be ignored) — it's a code-level gate.

Three-level deterministic verdicts

The verdict engine (assess_direction) examines the Top-15 search results and resolves to one of three levels:

Entity / GREEN: The top candidate is strong enough (score ≥ 0.85), the gap is clear, and chemistry checks pass. Evidence is sufficient to act.

Library Direction / YELLOW: No single compound can be locked (entity share is too low), but the candidates converge on a chemical family (direction share ≥ 50% with ≥ 2 supporting candidates). The agent names the family and generates a verification plan — a specific lab test to narrow it down.

Uncertain Direction / RED: Candidates diverge. The agent refuses to guess.

The confidence formula is deterministic:

confidence = library_confidence × rule_check_multiplier
Enter fullscreen mode Exit fullscreen mode

Where library_confidence comes from the search score and rule_check_multiplier comes from the chemistry cross-validation (seven checks including forbidden functional groups). Three consecutive runs on the same input return identical numbers.

Cross-validation: negative evidence matters

The cross_validate tool runs seven chemistry checks against the top candidate:

  1. Lead-score gap — is the top match meaningfully ahead?
  2. Material-family functional groups — does the spectrum show the bands this material family must show?
  3. Hard-forbidden groups — does the spectrum show bands this family must not show? (A polyolefin candidate with a strong 1730 cm⁻¹ C=O band is chemistry-inconsistent, regardless of its similarity score.)
  4. Peak coverage — how many diagnostic peaks are accounted for?
  5. Background contamination — CO₂, moisture, or atmospheric interference
  6. Baseline quality — offset, tilt, saturation
  7. Overall match quality — composite score

Each check returns PASS or FAIL with a specific reason. A FAIL on hard-forbidden groups is a veto — it overrides the similarity score.

These rules come from domain knowledge, not from training data. They live as auditable Python code:

FAMILY_REQUIRED_BANDS = {
    "styrenic": [(3000, 3100, "aromatic C-H stretch"),
                 (1600, 1610, "aromatic C=C ring"),
                 (690, 760, "aromatic C-H out-of-plane")],
    ...
}

FAMILY_FORBIDDEN_BANDS = {
    "polyolefin": [(1700, 1750, "C=O stretch — not expected in polyolefin")],
    ...
}
Enter fullscreen mode Exit fullscreen mode

Reproducibility contract

The demo includes a RED sample where the agent returns score=0.8029, confidence=0.6423. Three consecutive runs return the same numbers. This is by design: the LLM's reasoning route may vary (it might call tools in a different order), but the deterministic verdict does not change because the numbers come from Python, not from the model.

We verify this with a stability test:

Run 1: score=0.8029, confidence=0.6423, level=uncertain_direction
Run 2: score=0.8029, confidence=0.6423, level=uncertain_direction
Run 3: score=0.8029, confidence=0.6423, level=uncertain_direction
Enter fullscreen mode Exit fullscreen mode

Audit trail

Every tool call, LLM request, and UI event is logged as a timestamped JSON entry:

{
  "timestamp": "2026-07-07T12:06:23.668Z",
  "event": "tool_result",
  "tool": "assess_direction",
  "result": {
    "resolved_level": "library_direction",
    "entity_share": 0.069,
    "direction_share": 0.666,
    "dominant_direction": "fatty_ester"
  }
}
Enter fullscreen mode Exit fullscreen mode

When someone asks "how did the AI reach this conclusion?", the answer is in the log — every step, every number, every tool call.

What I'd do differently

The current cross-validation rules cover common material families but not all of them. A more complete system would have hundreds of rules covering polymers, minerals, pharmaceuticals, and biologics. The architecture supports it — adding a new family is adding a dictionary entry — but the knowledge encoding takes time.

I'd also like to support multi-spectrum sessions: upload the original spectrum, then an extract, then an ash — and let the agent maintain a hypothesis state machine across all three, proposing the next experiment at each step. That's how real formulation analysis works.

Try it

Built with qwen3.7-max on Alibaba Cloud DashScope, Python, FastAPI, vanilla JS. No framework dependencies.


*Built for the Qwen Cloud Hackathon 2025, Track 4: Autopilot Agent.

Top comments (0)