v2 chunk 1: scaffold note formatter (Path B parallel artifact)

Begin v2 (in-text citation note form) as a parallel artifact to the
v1 bibliography formatter, per the Path B architectural decision:
no shared mutable state, no shared prompt content, no v1 changes.

New artifacts:

- src/cmos/parser.py: add find_notes() / NoteDefinition /
  NotesParseResult as siblings to split_bibliography(). Targets
  pandoc-style markdown footnote definitions [^marker]: text.
  Single-line only; multi-line continuation deferred.

- src/cmos/note_formatter.py: new module mirroring formatter.py.
  17-rule SYSTEM_PROMPT_NOTES for CMOS 18 first-occurrence note
  form. Reuses cmos.runtime_validator.validate() unchanged — its
  structural checks all apply to note form too. Caller injection,
  retry loop, and model selection mirror v1.

- tests/test_parser.py: 7 new tests for find_notes().

- tests/test_note_formatter.py: 11 new tests mirroring v1 test
  discipline (no API calls, fake-caller injection, retry semantics,
  prompt smoke checks).

- exemplars/notes/: new subdirectory with 3 hand-synthesized
  first-occurrence note exemplars (book, journal article, chapter
  in edited book). Uses expected_note as the field name (not v1's
  expected_bibliography). harness/score.py:load_exemplars uses a
  non-recursive glob, so the v1 canary loader does not see these —
  Path B isolation is automatic.

v1 untouched. v1 canary still loads exactly 17 exemplars. Full
test suite: 96 v1 + 18 new v2 = 114 passing.

Smoke-tested all 3 v2 exemplars against real GPT-5 (not fakes),
2 runs each. book_first_yu and journal_first_kwon: 4/4 byte-perfect
on the first try. chapter_first_doyle: 0/2, surfacing two known
prompt gaps for the next iteration:

  1. Publisher abbreviation not expanded ("U of Chicago Press"
     preserved instead of "University of Chicago Press"). v1's
     formatter.py rule 14 is missing from the v2 prompt.

  2. "ed." pluralized to "eds." for multiple editors. CMOS NB
     uses "ed." invariantly regardless of editor count.

Both gaps are addressable prompt edits, not architectural problems —
exactly the kind of finding the dev loop is designed to surface.

Out of scope (deferred to chunk 2 and later): CLI extension,
shortened-form generation, document reassembly, harness scoring
loop integration, v2 linter rules, real-draft testing.
This commit is contained in:
Mark Eaton
2026-04-11 18:20:50 -04:00
parent 2a8c72ae22
commit 28ca3ac928
7 changed files with 614 additions and 3 deletions
+34
View File
@@ -0,0 +1,34 @@
# First-occurrence full note for a single-author book.
#
# Mirrors the v1 exemplar `book_single_author_yu.toml` (same source, same
# author, same publisher) but expects CMOS 18 NOTE form rather than
# bibliography form. Cited passage is on a fictional specific page (45)
# to exercise the rule that note form takes a specific page, not a
# range.
#
# Exercises:
# - Author in NORMAL order ("Charles Yu", not "Yu, Charles")
# - Comma separation between elements (vs periods in bibliography)
# - Publisher in PARENTHESES with year
# - No place of publication (CMOS 14.30, 18th ed.)
# - Specific page number
# - Italicized book title
# - Terminal period at end of note
#
# Field naming note: this exemplar uses `expected_note` (not v1's
# `expected_bibliography`). The v2 scoring harness loader is not yet
# wired up; chunk 1 of v2 only smoke-tests these via direct tomllib
# loading. The field name change is forward-compatible.
source = "Real published book; this is a fabricated specific page citation for testing note form"
type = "note_book"
tags = ["note", "book", "single-author", "first-occurrence"]
canary = true
messy_input = "yu, charles. interior chinatown. New York: Pantheon Books, 2020. p. 45."
expected_note = "Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45."
[canonical]
author = "Charles Yu"
title = "Interior Chinatown"
publisher = "Pantheon Books"
year = 2020
page = 45
+32
View File
@@ -0,0 +1,32 @@
# First-occurrence full note for a chapter in an edited book.
#
# Mirrors the v1 exemplar `book_chapter_doyle.toml` (same chapter, same
# editors, same publisher, same year) but expects CMOS 18 NOTE form
# rather than bibliography form. The cited passage is on a fictional
# specific page (117) within the chapter's full range.
#
# Exercises:
# - Author in NORMAL order ("Kathleen Doyle")
# - Chapter title in straight quotes with closing comma INSIDE the
# quotes ("The Queen Mary Psalter,")
# - Lowercase "in" before the italicized book title (CMOS note form)
# - "ed." abbreviation, NOT "Edited by"
# - Editors in NORMAL order ("P. J. M. Marks and Stephen Parkin")
# - Publisher in PARENTHESES with year
# - Specific page citation
# - Terminal period
source = "Real published chapter; specific cited page is fabricated for note-form testing"
type = "note_chapter"
tags = ["note", "chapter", "edited-volume", "first-occurrence", "two-editors"]
canary = true
messy_input = "Kathleen Doyle, 'the queen mary psalter,' in The Book by Design: The Remarkable Story of the World's Greatest Invention, P. J. M. Marks and Stephen Parkin eds., Chicago: U of Chicago Press, 2023, pp. 114-120, cited p. 117."
expected_note = "Kathleen Doyle, \"The Queen Mary Psalter,\" in *The Book by Design: The Remarkable Story of the World's Greatest Invention*, ed. P. J. M. Marks and Stephen Parkin (University of Chicago Press, 2023), 117."
[canonical]
author = "Kathleen Doyle"
chapter_title = "The Queen Mary Psalter"
book_title = "The Book by Design: The Remarkable Story of the World's Greatest Invention"
editors = "ed. P. J. M. Marks and Stephen Parkin"
publisher = "University of Chicago Press"
year = 2023
page = 117
+34
View File
@@ -0,0 +1,34 @@
# First-occurrence full note for a journal article with DOI.
#
# Mirrors the v1 exemplar `journal_with_doi_kwon.toml` (same article,
# same author, same DOI) but expects CMOS 18 NOTE form rather than
# bibliography form. The cited passage is on a fictional specific
# page within the article's full range — the bibliography form has
# `181859`, the note form cites a single page like `1830`.
#
# Exercises:
# - Author in NORMAL order ("Hyeyoung Kwon")
# - Article title in straight quotes with closing comma INSIDE the
# quotes ("Inclusion Work: ...,")
# - Italicized journal name
# - Volume / issue / year format same as bibliography:
# *Journal* VOL, no. ISSUE (YEAR)
# - Specific page after the (YEAR): construct
# - DOI as full https URL
# - Terminal period
source = "Real published article; specific cited page is fabricated for note-form testing"
type = "note_journal"
tags = ["note", "journal", "single-author", "first-occurrence", "doi"]
canary = true
messy_input = "Kwon, Hyeyoung. \"inclusion work: children of immigrants claiming membership in everyday life.\" American Journal of Sociology, Vol. 127, Issue 6, 2022, pp. 1818-1859. DOI: 10.1086/720277. (cited p. 1830)"
expected_note = "Hyeyoung Kwon, \"Inclusion Work: Children of Immigrants Claiming Membership in Everyday Life,\" *American Journal of Sociology* 127, no. 6 (2022): 1830, https://doi.org/10.1086/720277."
[canonical]
author = "Hyeyoung Kwon"
article_title = "Inclusion Work: Children of Immigrants Claiming Membership in Everyday Life"
journal = "American Journal of Sociology"
volume = 127
issue = 6
year = 2022
page = 1830
doi = "https://doi.org/10.1086/720277"
+250
View File
@@ -0,0 +1,250 @@
"""Note formatter — the v2 inner-loop artifact for CMOS 18 NB note form.
Path B sibling of ``cmos.formatter``: a separate prompt, separate exemplar
corpus, separate inner-loop iteration target. The two artifacts share NO
mutable state and NO prompt content. v1 ``format_bibliography_entry``
remains untouched as v2 evolves.
Some boilerplate (OpenAI client setup, reasoning-model detection, retry
loop) is intentionally duplicated rather than imported from
``cmos.formatter``. The duplication is small (~30 lines) and the
independence is more valuable than DRY here — if the two formatters ever
need different models, retry counts, or callers, the change is local.
Caller injection: ``format_note_entry`` accepts a ``caller`` shim so tests
and the harness can substitute a fake for the OpenAI call without touching
the network. The default caller reads ``OPENAI_API_KEY`` (via
python-dotenv) and calls the configured model with ``temperature=0`` for
non-reasoning models.
Scope of v2 iteration 1 (this commit): FIRST-OCCURRENCE FULL NOTE form
only. Shortened subsequent-citation form is deferred — that requires
state across notes which the formatter does not currently track. The
intended pipeline is: format every note as full form, then have a
separate deterministic Python pass identify repeated sources and
generate shortened forms from the full forms. That second pass is not
yet implemented.
"""
from __future__ import annotations
import os
from typing import Callable
from dotenv import load_dotenv
from cmos.runtime_validator import validate
# Default model. Override with OPENAI_MODEL=... in your .env.
MODEL = os.environ.get("OPENAI_MODEL", "gpt-5")
# How many extra attempts after the first if the runtime validator rejects
# the candidate. Mirrors v1 formatter behavior — see cmos.formatter and
# the project memory on GPT-5 nondeterminism.
DEFAULT_MAX_RETRIES = 2
# SYSTEM_PROMPT_NOTES: the v2 note formatter's program. Iterated by the
# autoresearch loop independently of v1's SYSTEM_PROMPT. Keep it explicit,
# versioned via git history, and traceable to CMOS 18 sources.
SYSTEM_PROMPT_NOTES = """\
You are a careful Chicago Manual of Style (CMOS), 18th edition,
notes-and-bibliography NOTE formatter. You produce CMOS 18 NOTE form
(not bibliography form).
Your task: given a single messy citation entry in English (typically
the body of a markdown footnote definition), rewrite it in CMOS 18
FIRST-OCCURRENCE FULL NOTE form. Output the formatted note text and
nothing else — no commentary, no leading number ("1.", "2."), no code
fences, no surrounding quotes.
Rules you must follow (CMOS 18th edition specifically, NOTE form):
1. AUTHOR NAMES are in NORMAL order ("First Last"), NOT inverted.
- One author: "Charles Yu"
- Two authors: "Charles Yu and Hyeyoung Kwon"
- Three authors: "Charles Yu, Hyeyoung Kwon, and Kathleen Doyle"
- Four or more authors: first author followed by ", et al." (CMOS
14.76: notes use first author + et al. for 4+ authors, even
though bibliography lists more before truncating).
Corporate authors (e.g., "Google", "Modern Language Association")
appear as-is, not reordered.
Non-Western names already in family-first order (e.g., "Liu Xinwu",
"Murakami Haruki") stay as-is — do not Western-order them.
2. ELEMENT SEPARATION uses COMMAS, not periods. A note is one
sentence-like construction terminated by a single period at the
very end. Periods are NOT used between author / title / journal
/ publisher in note form. This is the most visible difference
from bibliography form.
3. ITALIC AND QUOTE CONVENTIONS are the same as bibliography form:
book titles and journal titles are italicized using Markdown
asterisks (*Title*); article titles and chapter titles are
wrapped in straight double quotes. In NOTE form the closing
comma goes INSIDE the closing quote of the article/chapter
title: "Article Title," (not "Article Title",).
4. BOOK NOTE FORM:
Author, *Book Title* (Publisher, Year), specific-page.
The publisher and year are in PARENTHESES, separated by a comma.
Place of publication is NOT included (CMOS 14.30, 18th ed.).
5. JOURNAL ARTICLE NOTE FORM:
Author, "Article Title," *Journal Title* VOL, no. ISSUE (YEAR):
specific-page, https://doi.org/10.xxxx/yyyy.
Note that the closing comma after the article title is INSIDE
the quotes, and the colon between (YEAR) and the page is NOT
preceded by a space.
6. CHAPTER IN EDITED BOOK NOTE FORM:
Author, "Chapter Title," in *Book Title*, ed. Editor Names
(Publisher, Year), specific-page.
Use "ed." (abbreviated), NOT "Edited by". The lowercase "in"
before the italicized book title is part of CMOS note form
and should appear as shown.
7. TRANSLATED WORKS use "trans." (abbreviated), NOT "Translated by".
8. SPECIFIC PAGE: notes cite the specific page (or page range) of
the passage being referenced, NOT the full page range of the
article. If the source provides only a full article range and
no specific page, use the first page of the range. Do NOT
fabricate a more specific page number than the source provides.
9. PREFER DOIS over generic URLs. When a DOI is present, format it
as a full URL: https://doi.org/10.xxxx/yyyy (no "doi:" prefix,
no bare DOI).
10. NO IBID. CMOS 18 deprecates "Ibid." entirely. Do not produce it
under any circumstances, even if the input contains it. If the
input is an "Ibid." reference, this iteration of the formatter
cannot resolve it (shortened-form generation is out of scope).
Return the cleanest possible full-form note from whatever
information the messy input contains.
11. PRESERVE TERMINAL PUNCTUATION inside titles. If a title ends in
"?" or "!", keep that punctuation inside the closing quote and
add a separating comma OUTSIDE the closing quote in note form.
Example: "Are Flax Seeds All That?," *New York Times*, ...
Do NOT silently replace title-internal "?" or "!" with a comma
or period.
12. HEADLINE-STYLE CAPITALIZATION for titles, with the same CMOS
8.159 carve-outs as v1 bibliography form: lowercase prepositions
regardless of length (about, above, across, after, against,
among, around, as, at, before, beyond, by, for, from, in, into,
of, on, over, through, to, under, until, up, with, within, etc.)
UNLESS the preposition is the first or last word of the title
or subtitle. SUBORDINATING conjunctions (If, That, Because,
Although, Whether, Unless) are CAPITALIZED. Apply this rule
INDEPENDENTLY of the source's casing.
13. PRESERVE DELIBERATE LOWERCASING of proper nouns (the journal
"portal: Libraries and the Academy", authors like "bell hooks",
"danah boyd", "e e cummings", "k.d. lang", brand names like
"iPhone", "eBay"). When in doubt about whether a lowercased form
is intentional, err on the side of preserving the source's form.
14. PERIODICAL NAMES: drop a leading "The" from any periodical name
(newspaper, magazine, journal). Write "New York Times", not
"The New York Times"; "Library Quarterly", not "The Library
Quarterly". This rule applies ONLY to periodicals — do NOT drop
a leading "The" from a BOOK title or REPORT title.
15. INCLUSIVE-NUMBER ELISION for any page range you do output (CMOS
9.61): use the same elision rules as v1 — 1-99 keep both numbers
(3-10, 71-72); 110-199 use two-digit elision (137-49, 1818-59);
across hundreds boundary use full numbers (799-810, 1496-1504).
Always use an en-dash (), never an ASCII hyphen (-), in number
ranges. (Most notes cite a single page, so this rule fires
rarely, but it applies when needed.)
16. THE NOTE NUMBER PREFIX ("1. ", "2. ") is NOT part of the
formatted note text. The numbering is supplied externally by
the document or by markdown footnote rendering. DO NOT include
a leading number in your output.
17. DO NOT invent, fetch, or guess missing metadata. If a field is
missing in the source, leave it missing. Do not fabricate
authors, publishers, years, page numbers, URLs, or DOIs.
Output ONLY the single reformatted note. No preamble, no explanation,
no leading number, no code fences.
"""
Caller = Callable[[str, str], str]
def build_user_message(messy_entry: str) -> str:
return (
"Reformat the following citation entry to CMOS 18 notes-and-bibliography "
"FIRST-OCCURRENCE FULL NOTE form (note form, not bibliography form). "
"Output only the reformatted note text — no leading number.\n\n"
f"{messy_entry}"
)
def _is_reasoning_model(model: str) -> bool:
"""Reasoning-model families (GPT-5, o-series) don't accept temperature overrides."""
prefixes = ("gpt-5", "o1", "o3", "o4")
return any(model.startswith(p) for p in prefixes)
def _openai_caller(system: str, user: str) -> str:
"""Default caller — hits the real OpenAI API.
Not exercised in unit tests. Integration tests or real loop runs
use this path and require ``OPENAI_API_KEY`` in the environment or
a .env file at the project root.
"""
load_dotenv()
from openai import OpenAI # imported lazily so unit tests do not need the network
client = OpenAI()
kwargs: dict = {
"model": MODEL,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
}
if not _is_reasoning_model(MODEL):
kwargs["temperature"] = 0
response = client.chat.completions.create(**kwargs)
return response.choices[0].message.content or ""
def format_note_entry(
messy_entry: str,
caller: Caller | None = None,
max_retries: int = DEFAULT_MAX_RETRIES,
) -> str:
"""Reformat a single messy citation to CMOS 18 first-occurrence note form.
Pass a ``caller`` shim to avoid the real API (used by tests and the
harness when running with a fake formatter for loop smoke tests).
Reasoning models (gpt-5, o-series) are nondeterministic. The candidate
is checked against ``cmos.runtime_validator`` after each call; if it
fails any structural sanity check (missing terminal period, dropped
italics, stray Ibid., unbalanced quotes/asterisks), the formatter
retries up to ``max_retries`` more times. The runtime validator is
intentionally weaker than a full CMOS rule check because at runtime
there is no Exemplar — only structural rules independent of source
type apply. The validator is reused unchanged from v1 because all
its checks (terminal period, italic balance, no Ibid, balanced
quotes) apply to note form as well.
If every attempt fails validation, the LAST attempt is returned (we
don't raise — the caller still gets something usable, and the
failure will surface via the v2 scoring linter or human review).
"""
call = caller or _openai_caller
user_message = build_user_message(messy_entry)
last: str = ""
for _ in range(max_retries + 1):
last = call(SYSTEM_PROMPT_NOTES, user_message).strip()
if validate(last).passed:
return last
return last
+61 -2
View File
@@ -5,8 +5,18 @@ section as running until the next level-2 (``##``) heading or end of file,
and return each non-blank line as one entry. Bullet markers (``- ``, ``* ``) and return each non-blank line as one entry. Bullet markers (``- ``, ``* ``)
at the start of a line are stripped. at the start of a line are stripped.
Out of scope in v1: in-prose citation rewriting, multi-line entries, nested v2 scope (notes): ``find_notes`` locates pandoc-style markdown footnote
sections, alternative heading names (e.g., "Works Cited", "References"). definitions ``[^marker]: text`` anywhere in the document. Single-line
definitions only — multi-line continuation (indented continuation lines)
is deferred. The reference markers in prose (the ``[^marker]`` references
themselves, without the trailing colon) are NOT collected; only the
definitions need reformatting.
Out of scope in v1: in-prose citation rewriting, multi-line entries,
nested sections, alternative heading names (e.g., "Works Cited",
"References"). Out of scope in v2 (so far): multi-line note definitions,
shortened-form generation, reassembly of formatted notes back into the
document.
""" """
from __future__ import annotations from __future__ import annotations
@@ -26,9 +36,31 @@ class ParseResult:
after: str after: str
@dataclass
class NoteDefinition:
"""A single pandoc-style markdown footnote definition.
``marker`` is the identifier between ``[^`` and ``]:`` (e.g., ``"1"``
or ``"smith2020"``). ``text`` is the note body with surrounding
whitespace stripped. ``line_number`` is the 0-indexed line in the
source document where the definition appeared — currently unused but
captured for future reassembly support.
"""
marker: str
text: str
line_number: int
@dataclass
class NotesParseResult:
definitions: list[NoteDefinition]
_HEADING_RE = re.compile(r"^##\s+bibliography\s*$", re.IGNORECASE) _HEADING_RE = re.compile(r"^##\s+bibliography\s*$", re.IGNORECASE)
_LEVEL_TWO_RE = re.compile(r"^##\s+\S") _LEVEL_TWO_RE = re.compile(r"^##\s+\S")
_BULLET_RE = re.compile(r"^[-*]\s+") _BULLET_RE = re.compile(r"^[-*]\s+")
_NOTE_DEF_RE = re.compile(r"^\[\^([^\]]+)\]:\s*(.*?)\s*$")
def split_bibliography(text: str) -> ParseResult: def split_bibliography(text: str) -> ParseResult:
@@ -64,3 +96,30 @@ def split_bibliography(text: str) -> ParseResult:
entries.append(stripped) entries.append(stripped)
return ParseResult(before=before, entries=entries, after=after) return ParseResult(before=before, entries=entries, after=after)
def find_notes(text: str) -> NotesParseResult:
"""Find pandoc-style markdown footnote definitions in ``text``.
Targets the most common form: ``[^marker]: note text`` on a single
line. The marker can be numeric (``1``) or named (``smith2020``).
Multi-line definitions (where the note body continues on indented
subsequent lines) are out of scope for this iteration; only the
first line is captured.
Returns a ``NotesParseResult`` with definitions in the order they
appear in the document. If the document contains no footnote
definitions, returns an empty list rather than raising — a draft
with no notes is a valid (if uninteresting) input.
"""
definitions: list[NoteDefinition] = []
for line_number, line in enumerate(text.splitlines()):
match = _NOTE_DEF_RE.match(line)
if match is None:
continue
marker = match.group(1)
body = match.group(2)
definitions.append(
NoteDefinition(marker=marker, text=body, line_number=line_number)
)
return NotesParseResult(definitions=definitions)
+117
View File
@@ -0,0 +1,117 @@
"""Tests for src/cmos/note_formatter.py — the v2 inner-loop iterable artifact.
Mirrors tests/test_formatter.py in shape and discipline. The note formatter
is iterated independently of the bibliography formatter (Path B); these
tests pin the stable seams (caller injection, retry loop, prompt smoke
checks) and leave LLM output shape testing to the v2 exemplar canary.
No real API calls in this file. Tests use injected fake callers.
"""
from cmos.note_formatter import (
MODEL,
SYSTEM_PROMPT_NOTES,
build_user_message,
format_note_entry,
)
def test_default_model_is_gpt5():
# Same default as v1. Override with OPENAI_MODEL=... in .env.
assert MODEL == "gpt-5"
def test_system_prompt_mentions_cmos_18_note_form():
assert "CMOS" in SYSTEM_PROMPT_NOTES
assert "18" in SYSTEM_PROMPT_NOTES
assert "NOTE" in SYSTEM_PROMPT_NOTES
def test_system_prompt_mentions_no_inversion():
# The single most distinctive difference from bibliography form: notes
# use normal-order author names ("First Last"), not inverted.
lower = SYSTEM_PROMPT_NOTES.lower()
assert "normal" in lower or "not inverted" in lower
def test_system_prompt_mentions_comma_separation():
# The second most distinctive difference: commas between elements,
# not periods.
lower = SYSTEM_PROMPT_NOTES.lower()
assert "comma" in lower
def test_system_prompt_excludes_leading_number():
# The note number prefix ("1. ", "2. ") is supplied externally; the
# formatter must not include it. The prompt must say so.
lower = SYSTEM_PROMPT_NOTES.lower()
assert "leading number" in lower or "do not include" in lower
def test_system_prompt_mentions_no_ibid():
# CMOS 18 deprecates Ibid., same as v1.
assert "Ibid" in SYSTEM_PROMPT_NOTES or "ibid" in SYSTEM_PROMPT_NOTES.lower()
def test_build_user_message_contains_the_messy_input():
msg = build_user_message("yu, charles. interior chinatown. 2020, 45")
assert "yu, charles. interior chinatown. 2020, 45" in msg
def test_note_formatter_uses_injected_caller():
"""Same caller-injection seam as v1. Tests can substitute a fake without
touching the network."""
recorded: dict = {}
def fake_caller(system: str, user: str) -> str:
recorded["system"] = system
recorded["user"] = user
return "Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45."
output = format_note_entry(
"yu, charles. interior chinatown. New York: Pantheon Books, 2020, p 45.",
caller=fake_caller,
)
assert output == "Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45."
assert recorded["system"] == SYSTEM_PROMPT_NOTES
assert "yu, charles" in recorded["user"]
def test_note_formatter_strips_whitespace_from_caller_output():
def fake_caller(system: str, user: str) -> str:
return " Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45. \n"
output = format_note_entry("anything", caller=fake_caller)
assert output == "Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45."
def test_note_formatter_retries_on_validator_failure():
"""If the runtime validator rejects the first attempt (e.g., italics
dropped), the formatter retries. Same retry semantics as v1."""
attempts = []
def flaky_caller(system: str, user: str) -> str:
attempts.append(len(attempts) + 1)
if len(attempts) == 1:
# First attempt: italics dropped — validator should fail.
return "Charles Yu, Interior Chinatown (Pantheon Books, 2020), 45."
return "Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45."
output = format_note_entry("messy yu", caller=flaky_caller)
assert "*Interior Chinatown*" in output
assert len(attempts) == 2
def test_note_formatter_returns_last_attempt_after_max_retries():
"""If every attempt fails validation, return the last attempt and don't
loop forever. Same as v1 — the failure surfaces via scoring or human
review, not via a runtime exception."""
attempts = []
def always_bad(system: str, user: str) -> str:
attempts.append(1)
return "Charles Yu, Interior Chinatown (Pantheon Books, 2020), 45."
output = format_note_entry("messy", caller=always_bad, max_retries=2)
assert len(attempts) == 3 # 1 initial + 2 retries
assert "*" not in output
+86 -1
View File
@@ -4,11 +4,15 @@ v1 scope: locate a ``## Bibliography`` heading (case-insensitive) and return
the prose before it, the list of entries inside it, and the prose after it. the prose before it, the list of entries inside it, and the prose after it.
The section ends at the next ``##`` (level-2) heading or end of file. The section ends at the next ``##`` (level-2) heading or end of file.
Blank lines and bullet markers (``- `` or ``* ``) are stripped from entries. Blank lines and bullet markers (``- `` or ``* ``) are stripped from entries.
v2 (notes) parser scope: find pandoc-style markdown footnote definitions
``[^marker]: text`` anywhere in the document. Single-line definitions only;
multi-line continuation is deferred to a later chunk.
""" """
import pytest import pytest
from cmos.parser import NoBibliographyError, split_bibliography from cmos.parser import NoBibliographyError, find_notes, split_bibliography
def test_three_entries_under_bibliography_heading(): def test_three_entries_under_bibliography_heading():
@@ -74,3 +78,84 @@ Only entry.
def test_no_bibliography_raises(): def test_no_bibliography_raises():
with pytest.raises(NoBibliographyError): with pytest.raises(NoBibliographyError):
split_bibliography("## Introduction\n\nJust prose.\n") split_bibliography("## Introduction\n\nJust prose.\n")
# --- find_notes (v2 parser, pandoc-style markdown footnote definitions) ----
def test_find_notes_single_definition():
draft = """\
Some prose with a citation.[^1]
[^1]: Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45.
"""
result = find_notes(draft)
assert len(result.definitions) == 1
assert result.definitions[0].marker == "1"
assert result.definitions[0].text == "Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45."
def test_find_notes_multiple_definitions_in_order():
draft = """\
Prose.[^1] More prose.[^2] And again.[^3]
[^1]: First note.
[^2]: Second note.
[^3]: Third note.
"""
result = find_notes(draft)
assert len(result.definitions) == 3
assert [d.marker for d in result.definitions] == ["1", "2", "3"]
assert [d.text for d in result.definitions] == [
"First note.",
"Second note.",
"Third note.",
]
def test_find_notes_named_marker():
draft = """\
Citation.[^smith2020]
[^smith2020]: Smith, J. (2020). Some work.
"""
result = find_notes(draft)
assert len(result.definitions) == 1
assert result.definitions[0].marker == "smith2020"
assert result.definitions[0].text == "Smith, J. (2020). Some work."
def test_find_notes_returns_empty_when_no_definitions():
draft = "Just prose with no footnote definitions.\n"
result = find_notes(draft)
assert result.definitions == []
def test_find_notes_records_line_number():
draft = """\
Line zero.
Line one.
[^1]: Note on line three.
"""
result = find_notes(draft)
assert len(result.definitions) == 1
assert result.definitions[0].line_number == 3
def test_find_notes_strips_trailing_whitespace_from_text():
draft = "[^1]: Note text with trailing spaces. \n"
result = find_notes(draft)
assert result.definitions[0].text == "Note text with trailing spaces."
def test_find_notes_definition_can_appear_before_reference():
# Pandoc allows definitions anywhere in the doc, not just at the bottom.
draft = """\
[^1]: A definition before the reference.
Some prose that cites it.[^1]
"""
result = find_notes(draft)
assert len(result.definitions) == 1
assert result.definitions[0].text == "A definition before the reference."