TechJam Conversational E-Commerce Search — Submission
Our submission for the TikTok TechJam 2026, Track 4: Shopping Copilot — AI Conversational Search and Recommendations.
The task: build a multi-turn shopping agent that, within at most 10 turns, asks useful clarification
questions and surfaces a hidden target product (identified only by parent_asin) in a ranked Top‑10
list, against a frozen 50,000‑item Clothing_Shoes_and_Jewelry catalog and a deterministic customer
simulator. Full rules are in docs/competition_specification.md
and docs/submission_rules.md; the organizer-provided quick start is in
TECHJAM-README.md.
Submission Entry Point
- Agent class:
starter/agent.py—Agent - Interface:
reset(session_id, user_profile)/respond(session_id, user_message, turn, top_k), matching the required contract exactly (seedocs/agent_api_contract.json). - Dependencies: Python standard library only (
sqlite3,urllib,json,re,math,dataclasses). Norequirements.txtis needed to run the agent itself. - Earlier development iterations (
baseline-agent.py,jzy_agent*.py,my_agent.py) are kept in the full development repository's history but are not shipped here;starter/agent.pyis the only submitted entry point.
How to Run
# 1. data/catalog.jsonl (50,000 rows) and data/public_set.jsonl are already included in this repo.
# 2. (Optional) Start a local Ollama server for LLM-assisted extraction/reranking
ollama serve
ollama pull qwen3:4b
ollama pull nomic-embed-text # only needed for RERANK_METHOD=embedding
# 3. Run the official local evaluator against the 200-session public set
python3 -m evaluator.local_evaluator
This writes per-session results and aggregate metrics to results.json and prints the aggregate
summary to stdout. Requires Python 3.10+ (matching the organizer's recommendation); the agent code
itself is written to also run under 3.9.
Method
Agent is a stateful hybrid retrieval system with an optional local LLM in the loop. Per session it
maintains a SessionState (accumulated constraints, asked/no-preference attributes, excluded ASINs,
conversation route). Each respond() call runs the following pipeline:
flowchart TD
Start(["reset(): initialize session"]) --> Msg(["Customer message received"])
Msg --> Respond["respond(): exclude ASINs\nalready shown this session"]
Respond --> NoPref
subgraph EXTRACT["① Constraint & Intent Extraction"]
NoPref{"Exact-phrase match for\n'no preference for X'?"}
Fast["Mark attribute X as\nno_preference (skip LLM)"]
Extract[["LLM constraint & intent extraction\n(covers paraphrased no_preference\nand override)"]]
Fallback["Regex-based constraint extraction\n(material, color, budget, etc.)"]
Override{"Classified intent"}
Merge["Merge accepted constraints\ninto session state"]
NoPref -- match --> Fast --> Merge
NoPref -- "no match" --> Extract
Extract -- "unavailable / timeout /\ninvalid response" --> Fallback --> Override
Extract -- "valid response" --> Override
Override -- "override" --> Merge
Override -- "other" --> Merge
end
Merge --> Retrieve
subgraph RETRIEVE["② Route & Retrieve"]
Retrieve["BM25 retrieval (SQLite FTS5),\nweighted by buying vs. browsing route,\nrecalls max(top_k×8, 40) candidates"]
end
Retrieve --> Method
subgraph RERANK["③ Rerank"]
Method{"Rule-based rerank,\nthen RERANK_METHOD"}
LLMRerank[["LLM rerank: score\ntop-10 (0-4), reorder"]]
EmbedRerank[["Embedding rerank: cosine\nsimilarity over top-30"]]
Method -- "llm" --> LLMRerank
Method -- "embedding" --> EmbedRerank
end
LLMRerank --> Stop
EmbedRerank --> Stop
subgraph OUTPUT["④ Clarify & Respond"]
Stop{"turn ≥ 10, or\n(≤10 candidates & ≥2 constraints)?"}
None["ask_attribute = null"]
Gain["Score each unasked attribute\nby information gain over the\ncandidate pool (excl. brand/category)"]
Pick["Ask the highest-gain attribute;\nfixed priority order if no\nsplit is informative"]
Return["Return message,\nask_attribute, recommendations, usage"]
Stop -- yes --> None --> Return
Stop -- no --> Gain --> Pick --> Return
end
Return --> Hit{"Target parent_asin\nin top 10?"}
Hit -- hit --> End(["Session ends"])
Hit -- "miss, turn < 10" --> Msg
Hit -- "miss, turn == 10" --> End
classDef terminal fill:#dff5e3,stroke:#2f9e51,stroke-width:1.5px,color:#175c31;
classDef process fill:#e8f1fc,stroke:#2f6fb8,stroke-width:1.5px,color:#123a63;
classDef decision fill:#fff3d6,stroke:#c9891c,stroke-width:1.5px,color:#6b4a05;
classDef llmCall fill:#eee3fb,stroke:#7c4dcc,stroke-width:1.5px,color:#3a1d6e;
classDef fallback fill:#f1f1f1,stroke:#8a8a8a,stroke-width:1.5px,color:#3a3a3a;
class Start,Msg,End terminal;
class Respond,Fast,Merge,Retrieve,None,Gain,Pick,Return process;
class Extract,LLMRerank,EmbedRerank llmCall;
class Fallback fallback;
class NoPref,Override,Method,Stop,Hit decision;
Green = session start/end · blue = deterministic processing · amber = decision point · purple = local
Ollama call · gray = non-LLM fallback path. Implementation specifics (BM25 field weights, RRF fusion, RERANK_METHOD, information-gain
scoring) are in the numbered walkthrough below.
- Constraint & intent extraction. Each turn resolves through the following steps, in order:
- Fast path, no LLM call. If the message is an exact match for the simulator's own "no
preference for X" / "don't have a preference for X" phrasing (
_no_preference_attribute), that attribute is markedno_preferenceimmediately and the LLM is skipped entirely for this turn. - LLM parse. Otherwise the message is sent to a local Ollama chat model (
qwen3:4bby default,_llm_parse) with a JSON-schema-constrained prompt asking forconstraints(an array of{attribute, value, negated}), free-textsearch_phrases, anintentlabel (provide_constraint/no_preference/override/other),no_preference_attribute, and acategory_phrase. An unparseable first reply gets exactly one repair retry (same context plus an "invalid JSON, retry once" instruction) before the parse is treated as failed. - Closed-vocabulary validation.
intentandno_preference_attributeare accepted only if they fall inside their fixed value sets (_llm_intent); anything else is discarded.category_phraseis accepted as a new category constraint only if every one of its terms is copied from the raw message and stem-matches a term the catalog actually uses as a category head (_llm_category_terms) — a phrase that fails either check never reaches retrieval. - Per-attribute grounding of
constraints. Each entry is checked individually (_apply_llm_extraction): theattributemust be one of the ten allowed values; acategoryvalue's terms must fall inside the currently valid category scope (the original product type, or the replacement one from an override — see below); amaterialvalue must contain one of the fixed material keywords; ause_casevalue must contain one of the fixed use-case markers. Everysearch_phrasesentry must additionally be ≤8 tokens, ≤120 characters, and have every one of its tokens appear in the raw user message. A constraint or phrase that fails its check is dropped and logged with a reason string (rejected_constraints/rejected_search_phrases, visible viadebug_snapshot) rather than silently discarded — nothing is trusted just because the model produced it, only what can be traced back to the user's own words and the fixed vocabularies is kept. - Override ordering. When
intent == "override", accumulated constraints, asked attributes, and excluded-ASIN history are reset before the constraint checks above run, so a category-scope check for this turn is evaluated against the new (replacement) category rather than the stale one. - Regex fallback. If the LLM is disabled, the parse fails outright (timeout, transport error, or
still-invalid JSON after the retry), or a successful parse ends up with nothing accepted at all,
a deterministic fallback takes over for that turn: fixed material/color keyword matching, a budget
regex, the same "no preference for X" / override phrasings matched by simple regexes, and — only
when the LLM is fully disabled — every remaining non-stopword token in the message added to
search_phrasesso retrieval still has a keyword query to run.
- Fast path, no LLM call. If the message is an exact match for the simulator's own "no
preference for X" / "don't have a preference for X" phrasing (
- Routing. Sessions are classified
buying(≥2 active constraints, or buying-intent language) vs.browsing, which changes both retrieval weighting (the strict per-attribute expression described below is only added inbuyingmode) and whether the anonymizeduser_profile'spreference_tagsare folded into the query at all — they are only added inbrowsingmode, where explicit constraints are sparser and there is less to search on. - Retrieval. An in-memory SQLite FTS5 index (title/categories/features/details/store/description,
field-weighted BM25) is queried with both a broad (
OR) and, inbuyingmode, a strict (AND) per-attribute expression, fused via rank-based RRF. Products matching a user's explicitly negated value (e.g. "no leather") are filtered out entirely. - Rule-based rerank. Candidates are rescored using constraint-match bonuses/penalties, title/profile token overlap, and budget fit.
- Optional LLM/embedding rerank.
RERANK_METHODselects between two interchangeable rerankers applied to the rule-ranked shortlist:llm(default) asks Ollama to score each of the top ~10 candidates 0–4 against the active constraints via a strict JSON schema;embeddinginstead embeds the current intent and the top ~30 candidates withnomic-embed-textand blends cosine similarity into the rule score. Both fall back to the pure rule-based order on any transport or format error. - Clarification strategy.
_next_attribute()first checks two catch-all shortcuts before scoring anything: right after an Intent Override, and whenever the route isbuyingand the user has already declared at least oneno_preference, it immediately asks the simulator's catch-allotherattribute (once, until the next override resetsasked) rather than running the information-gain search below — both situations mean the most useful next question is "what else matters to you now", not a re-ranked guess from stale candidate data. Otherwise it picks the nextask_attributeby estimating information gain — grouping current candidates by coarse attribute value (skipping an attribute if fewer than half the candidates have a known value for it) and preferring the split that most reduces expected remaining candidates — with a deterministic fallback priority order when no split is informative.brandandcategoryare never asked, since the evaluator's simulator can never match a disclosed constraint to those two.
An Intent Override turn (detected by regex/LLM-classified intent="override") resets accumulated
constraints, asked attributes, and excluded-ASIN history while preserving the conversation transcript
and previously declared no-preferences, so a replaced preference doesn't get diluted by stale state.
Innovation Highlights
- Grounded, hallucination-resistant LLM extraction. The LLM never gets to inject a constraint,
search phrase, or category directly into retrieval — every
constraint.value,search_phrase, andcategory_phraseis re-validated against the raw user message and the fixed attribute/catalog vocabulary (_apply_llm_extraction,_llm_category_terms) before acceptance. This gives the precision of an LLM parser with the reliability of a rule-based one; nothing is trusted just because the model said so. - Swappable semantic reranker behind one config flag.
RERANK_METHOD=llmvsembeddingare two independent, fully implemented rerankers over the same rule-ranked shortlist — one an LLM relevance judge (0–4 JSON-schema scores), the other embedding cosine similarity — so the retrieval/rerank strategy can be swapped or A/B-tested without touching business logic (_rerankdispatch,_llm_rerank/_embedding_rerank). - Adaptive, information-theoretic clarification. Instead of a fixed question order,
_next_attributescores every still-askable attribute by how much it's expected to shrink the live candidate pool (gain = 1 - E[remaining]/Nover attribute-value groupings) and asks whichever question is most discriminating for the current candidates, not a generic checklist. - Negation as a hard retrieval filter, not just a score penalty. A stated exclusion (e.g. "no
leather") removes matching products from the candidate set entirely (
_violates_negated_constraints) rather than merely down-ranking them, so an excluded material/color can never leak into the Top 10. - State-surgical Intent Override handling. An override clears stale constraints, asked-attributes,
and excluded-ASIN history — but keeps the full transcript and previously declared no-preferences.
Clearing
excluded_asinswas itself an empirically-driven design decision, not a default: keeping it populated across an override was A/B-tested and measured to collapse override hit-rate from1.0to0.375on the public set, documented in_update_state. - Full per-turn decision trace without touching the scored API.
debug_snapshot(session_id)exposes every intermediate decision — accepted/rejected constraints, both BM25 expressions and their matches, rule-rerank scores, and the raw LLM/embedding rerank inputs and outputs — as a side channel the evaluator never scores, giving transparent, replayable explanations for why each recommendation and question was chosen.
Model Choice, Cost, and Network Requirements
- Model: Ollama running
qwen3:4blocally for constraint extraction and (default) reranking, andnomic-embed-textlocally whenRERANK_METHOD=embedding. Both are self-hosted, open-weight models — no external API key and no per-token cost. - Network: requires only a local connection to
http://127.0.0.1:11434(configurable viaOLLAMA_URL/OLLAMA_EMBED_URL); no outbound internet access is required at inference time. - Offline fallback: every LLM call path (
_ollama_chat,_ollama_embed) is wrapped so that a timeout, connection error, or malformed response is caught and the agent falls back to the regex/BM25/rule-based path instead of raising — the agent runs and remains scored even withLLM_ENABLED=0or no Ollama server present, at reduced ranking quality. - If the official harness disables network access entirely: set
LLM_ENABLED=0(and/orEMBED_ENABLED=0); the agent then runs purely on the SQLite FTS5/BM25 retrieval and rule-based constraint extraction and rerank described above, withusage.prompt_tokens/usage.completion_tokensreported as0. - Token usage: reported live per turn via the
usagefield, accumulated from Ollama's ownprompt_eval_count/eval_countresponse fields (seeAgent._ollama_chat). - Latency: each LLM call is bounded by
LLM_TIMEOUT(default 8s) /EMBED_TIMEOUT(default 8s) and runs synchronously withinrespond(). A one-off local measurement (same 10 candidates, same prompt content,qwen3:4b/nomic-embed-text) found the embedding reranker ~34x faster per rerank call (0.28s vs 9.29s), but only ~1.8x faster end to end over a full multi-turn conversation (14.4s vs 25.5s per sample, averaged over 3 sessions), because constraint extraction still runs the same chat call in both configurations and dominates per-turn latency. Seeevaluation_results/rerank_comparison_summary.mdfor the full comparison, including whyRERANK_METHOD=embeddingtrades some MRR for that speedup.
Configuration
All tuning is via environment variables (defaults shown):
| Variable | Default | Purpose |
|---|---|---|
LLM_ENABLED |
1 |
Set to 0/false/no to disable all LLM calls |
LLM_MODEL |
qwen3:4b |
Ollama chat model for extraction and LLM rerank |
OLLAMA_URL |
http://127.0.0.1:11434/api/chat |
Ollama chat endpoint |
LLM_TIMEOUT |
8 |
Per-request timeout (seconds) |
LLM_RERANK_LIMIT |
10 |
Candidates sent to the LLM reranker |
RERANK_METHOD |
llm |
llm or embedding — selects the reranker used in respond() |
EMBED_ENABLED |
1 |
Enables embedding calls when RERANK_METHOD=embedding |
EMBED_MODEL |
nomic-embed-text |
Ollama embedding model |
OLLAMA_EMBED_URL |
http://127.0.0.1:11434/api/embed |
Ollama embed endpoint |
EMBED_TIMEOUT |
8 |
Embedding request timeout (seconds) |
EMBED_RERANK_LIMIT |
30 |
Candidates sent to the embedding reranker |
EMBED_RERANK_WEIGHT |
1.2 |
Weight of cosine similarity in the fused embedding-rerank score |
Results
Full 200-session public set (data/public_set.jsonl), default configuration (RERANK_METHOD=llm,
qwen3:4b):
| Scenario | Samples | Hit Rate@10 | MRR | MTTC | Efficiency | TechnicalScore |
|---|---|---|---|---|---|---|
| Overall | 200 | 0.985 | 0.594 | 3.72 | 0.729 | 0.816 |
| Buying | 80 | 0.988 | 0.592 | 2.94 | 0.806 | 0.833 |
| Browsing | 80 | 0.988 | 0.592 | 3.93 | 0.708 | 0.813 |
| Intent Override | 30 | 1.000 | 0.623 | 5.17 | 0.583 | 0.804 |
| Boundary | 10 | 0.900 | 0.537 | 3.90 | 0.710 | 0.753 |
Only the Overall row's Efficiency/TechnicalScore is reported directly by the evaluator; the per-scenario
values are derived from each scenario's own Hit Rate@10/MRR/MTTC using the same official formula
(Efficiency = clip((11 - MTTC) / 10, 0, 1), TechnicalScore = 0.50·HitRate@10 + 0.30·MRR + 0.20·Efficiency).
Total reported token usage across all 200 sessions: 882,068 prompt tokens / 112,717 completion tokens.
The weak BM25-only starter agent scores Hit Rate@10 0.125, MRR 0.068034, MTTC 9.81 on the same
set (see docs/baseline_results.json); this submission's constraint
extraction, negation filtering, information-gain-driven clarification, and LLM/embedding reranking
account for the improvement over that baseline.
Reproduce with:
python3 -m evaluator.local_evaluator
Limitations
- LLM-assisted extraction and reranking depend on a locally reachable Ollama server; without one, the agent degrades to the regex/BM25 fallback path, which is measurably weaker (see the baseline numbers above).
qwen3:4b's structured-output reliability is the reasonLLM_RERANK_LIMITis capped at 10 rather than covering the full retrieved candidate pool — a larger required-JSON schema was A/B-tested and found to push invalid-JSON failures to ~65% on the public set (see the comment inAgent.__init__).- Coarse attribute-value extraction for the clarification strategy (
_product_attribute_values) is keyword/marker-based per attribute, not learned, so it can miss less common phrasings in the catalog text forstyle,feature, anduse_case. - No live latency instrumentation is included in the agent itself (only Ollama's reported token counts are captured at runtime); the per-call and end-to-end latency figures above are from a one-off manual timing run, not a continuously reported metric.
brandandcategoryare intentionally never asked asask_attribute, matching how this dataset's customer simulator behaves — this is dataset-specific and not a general assumption about shopping conversations.
Repository Layout
starter/agent.py submitted Agent implementation
evaluator/local_evaluator.py organizer-provided local simulator and scorer (not modified)
data/ public_set.jsonl + catalog.jsonl
docs/ competition spec, API contract, submission rules, baseline results
evaluation_results/ full-run outputs, logs, and rerank comparison for the Results section
demo/ demo assets and a live-run script/output
tests/ unit tests for the evaluator and Agent internals
scripts/ catalog inspection utilities used during development
TECHJAM-README.md original organizer quick-start
Built With
- ollama
- python
- qwen
Log in or sign up for Devpost to join the conversation.