Views
No views yet
Qwen/Qwen3.5-9B (loaded as AutoModelForImageTextToText). It is the
mathematical composition of:<tool_call>{...}</tool_call> per turn
(~20 actions covering front-matter, body structure, references). A companion
environment owns the JATS tree and applies actions, so tag-balance and
well-formedness are structurally guaranteed.⚠️ Inference requires both the model and the JATS environment. The model alone emits actions; the environment applies them to build XML state and produces the next observation. The minimal inference-only package (env, tool schemas, serializer, runnable example) is at https://github.com/parthsarin/agentic-jats-annotation-inference. Full training source (RL, reward, gold-scratchpad generation) is at https://github.com/parthsarin/jats-annotation-via-agentic-scratchpad.
1from peft import PeftModel
2from transformers import AutoModelForImageTextToText, AutoTokenizer
3
4base = AutoModelForImageTextToText.from_pretrained(
5 "Qwen/Qwen3.5-9B",
6 torch_dtype="bfloat16", trust_remote_code=True, device_map="auto",
7)
8model = PeftModel.from_pretrained(
9 base,
10 "public-knowledge-project/agentic-jats-annotation-qwen3.5-9b-lora-v4-rl-step25",
11)
12tok = AutoTokenizer.from_pretrained("Qwen/Qwen3.5-9B")Convert the following document to JATS XML using the available tools.
<source doc_id='12345-67890-1-CE'>
L0001: Intermittent Left Bundle Branch Block
L0002:
L0003: Mansoor Mozayan, MD, PhD, Marc Mugmon, MD
L0004:
L0005: Department of Medicine, MedStar Union Memorial Hospital, Baltimore, MD 21218
... (one line per markdown line, prefixed L<4-digit-line> with the original text)
</source>
<rid_table>
cit0001 -> 1
cit0002 -> 2
... (rid → display-label map for citations; usually built from the back-matter
reference list. If annotating from scratch, you can pass an empty table
and let the model emit refs without xref validation.)
</rid_table>
Emit one <tool_call>{...}</tool_call> per turn. Use short reasoning.<status>
open_elements: <sec depth=1 title='Introduction'>
remaining_unassigned: 32/97
emitted: 8 calls
</status>
<result>ok: <p> spans lines 21..23</result></tool_call> — the model is trained to emit exactly one
<tool_call>{...}</tool_call> block per turn.enable_thinking=False: the SFT teacher data never emitted thinking
content; passing enable_thinking=True makes the model ramble in
<think>...</think> and waste the token budget. Always pass it explicitly:1prompt_str = tok.apply_chat_template(
2 messages, tokenize=False, add_generation_prompt=True,
3 enable_thinking=False,
4)1inputs = tok(prompt_str, return_tensors="pt").to(model.device)
2out = model.generate(
3 **inputs, max_new_tokens=192, temperature=0.7,
4 stop_strings=["</tool_call>"], tokenizer=tok,
5)
6assistant_text = tok.decode(out[0, inputs.input_ids.shape[1]:], skip_special_tokens=False)1from src.env import JatsEnv, SYSTEM_PROMPT
2from src.serialize import calls_to_jats_xml
3
4env = JatsEnv(extras={
5 "reward_spec": {"method": "rule", "ground_truth": gold_jats_xml}, # optional
6 "extra_info": {
7 "doc_id": doc_id,
8 "markdown": md_text,
9 "rid_table": rid_table, # {"cit0001": "1", ...}
10 "system_prompt": SYSTEM_PROMPT,
11 },
12 "max_turns": 90,
13})
14messages, _ = env.init(prompt=None)
15
16for turn in range(env.max_turns):
17 prompt_str = tok.apply_chat_template(
18 messages, tokenize=False, add_generation_prompt=True, enable_thinking=False,
19 )
20 # ... call model.generate(...) -> assistant_text
21 messages.append({"role": "assistant", "content": assistant_text})
22 step = env.step(assistant_text)
23 if step["done"]:
24 break
25 messages.extend(step["observations"])
26
27final_xml = calls_to_jats_xml(env.state.emitted_calls,
28 md_lines=md_text.splitlines(),
29 rid_table=rid_table)set_article_title, add_contrib, add_affiliation,
start_abstract / end_abstract, add_keywordmark_section_start, mark_section_end, mark_paragraph,
mark_xref, mark_inline (italic / bold / sup / sub / underline),
mark_list_start / mark_list_item / mark_list_end,
mark_table, mark_figureadd_ref (preferred — folds whole reference into one call),
start_ref / ref_field / end_ref (alternative per-field path)skip_lines, unassign_lines, finishsrc/tools.py on GitHub.</tool_call>. The
model will emit one tool-call JSON per call. But you'll have to validate, apply,
and generate observations yourself — easier to pip install the inference package at https://github.com/parthsarin/agentic-jats-annotation-inference and use the env.system message at
inference; the line-numbered source markdown and rid_table go in the first
user message (see §2 above).1You annotate documents with JATS XML by emitting one tool call per turn.
2The environment owns the XML tree; you only emit JSON describing the next
3edit. Use <think>...</think> for brief reasoning (<=200 tokens) and then
4emit exactly ONE <tool_call>{...}</tool_call> block. Generation stops at
5</tool_call>; the env resumes after parsing.
6
7# Tool-call JSON shape
8
9Each call is a SINGLE FLAT JSON object whose discriminator field is "name"
10and whose other fields are the call's arguments at the SAME nesting level
11(NOT nested under an "arguments" or "params" key). Line numbers are
12INTEGERS (e.g. 5), not strings (e.g. NOT "L0005").
13
14Three correct examples:
15
16 <tool_call>{"name": "set_article_title", "line": 1, "title": "German-Austrian Consensus on Charcot Neuroarthropathy"}</tool_call>
17
18 <tool_call>{"name": "mark_section_start", "line": 5, "depth": 1, "title": "Introduction"}</tool_call>
19
20 <tool_call>{"name": "mark_xref", "line": 7, "target": "1", "ref_type": "bibr", "rid": "cit0001", "head": "guidelines (", "tail": ")."}</tool_call>
21
22Common WRONG shapes the parser rejects:
23 {"command": "...", ...} -- key must be "name"
24 {"name": "...", "arguments": {...}} -- args are FLAT, not nested
25 {"name": "...", "line": "L0001"} -- "line" is int, not "L..."
26
27# Tools (signatures: required fields then [optional])
28
29Front-matter:
30 set_article_title line:int, title:str
31 add_contrib surname:str, given_names:str, [contrib_type, initials, email, aff_rids:list[str]]
32 add_affiliation aff_id:str, text:str
33 start_abstract / end_abstract (no args)
34 add_keyword text:str
35
36Body:
37 mark_section_start line:int, depth:int(1..4), title:str, [sec_type]
38 mark_section_end depth:int
39 mark_paragraph start_line:int, end_line:int
40 mark_xref line:int, target:str, ref_type:"bibr"|"table"|"fig"|"sec"|"aff"|"fn", rid:str, [head, tail]
41 mark_inline line:int, target:str, tag:"italic"|"bold"|"sup"|"sub"|"underline", [head, tail]
42 mark_list_start list_type:"bullet"|"order"|"simple"|"alpha-lower"|"alpha-upper"
43 mark_list_item start_line:int, end_line:int
44 mark_list_end (no args)
45 mark_table start_line:int, end_line:int, [label, caption]
46 mark_figure line:int, [label, caption, graphic_href]
47
48Back / ref-list:
49 add_ref rid:str, label:str, publication_type:"journal"|"book"|"chapter"|"conf-proc"|"thesis"|"webpage"|"other", fields:list[{field:str, value:str}]
50 Preferred: emits a whole <ref> in one turn instead of start_ref/ref_field*N/end_ref.
51 `field` values: surname|given-names|year|article-title|source|volume|issue|fpage|lpage|pub-id-doi|pub-id-pmid|ext-link-uri|...
52 start_ref rid:str, label:str, [publication_type] # alternative to add_ref, used with ref_field/end_ref
53 ref_field field:str, value:str # field: surname|given-names|year|article-title|source|volume|issue|fpage|lpage|pub-id-doi|...
54 end_ref (no args)
55
56Meta:
57 skip_lines start_line:int, end_line:int, [reason]
58 unassign_lines start_line:int, end_line:int
59 finish (no args)
60
61# Semantic rules
62
63- depth must equal (parent_depth + 1), or 1 at top level.
64- mark_xref / mark_inline require the line to already be inside a <p>.
65- mark_xref's `rid` must reference an entry in the rid_table.
66- finish() requires zero open sections and zero unassigned non-empty lines.Qwen/Qwen3.5-9B (9B params, hybrid Gated DeltaNet + Sparse MoE,
native 262k ctx)eps_clip_low=0.2, eps_clip_high=0.32,
no KL loss, overlong shaping, zero-variance group filtering, no advantage
std-normalization. Per-turn rollout temperature 1.0, N_SAMPLES=16,
MAX_TURNS=45.(parent_tag, child_tag, depth, text_hash) tuples
between the model's emitted tool-call sequence and gold JATS body+front+back,
with a small recall floor and anti-hack guards| Model | mean reward | max reward | positive | best behavior |
|---|---|---|---|---|
| Old SFT (pre-line-anchored) | -0.87 | +0.71 | 3/20 | spam add_affiliation |
| 3-epoch SFT (line-anchored) | -1.65 | +0.70 | 3/20 | body annotation |
| This (SFT + RL step 25) | -1.59 | +0.91 | 4/20 | body + xref + inline |
mark_paragraph 99 / mark_xref 79 / skip_lines 51 / add_keyword 42 /
mark_section_start 39 / add_contrib 36 / add_affiliation 25 /
mark_inline 20. Significantly more diverse than SFT-only baselines.finish() rarely successful — most rollouts terminate via turn cap or
consecutive-error limit. Downstream consumers should serialize partial state
via src/serialize.py:calls_to_jats_xml.