19 Code: Agent 4 — Academic Writing & Publication
Folder: 02_agents_code/agent_04_academic_writing/ — self-contained; run python agent_04_academic_writing/main.py --help.
19.1 README
19.1.1 README.md
# Agent 4 — AI Academic Writing & Publication Agent ("From Data to Manuscript")
A multi-agent publishing assistant: **manuscript drafting → journal matching → simulated peer review (3 personas) → response letter + revised manuscript**.
```
notes.md ─► ManuscriptAgent (IMRaD draft + AI-use declaration)
─► JournalMatchingAgent (scores journals from journals.py catalogue)
─► ReviewerSimulationAgent (methodologist · domain expert · statistician)
─► RevisionAgent (point-by-point response letter, revised manuscript with bold changes)
```
## Files
| File | Purpose |
|------|---------|
| `main.py` | CLI (`--notes`, `--manuscript`, `--references`, `--field`, `--reviewers`) |
| `agents.py` | The four sub-agents + Pydantic models (JournalRanking, ReviewSet) |
| `journals.py` | Editable journal catalogue (replace with OpenAlex/Scimago/DOAJ in production) |
| `sample_notes.md` | Example study notes to try immediately |
| `llm.py` | Thin Claude client |
| `outputs/<slug>/` | `manuscript_v1.md`, `journal_matches.json`, `reviews.json`, `response_letter.md`, `manuscript_v2.md` |
## Run
```powershell
python agent_04_academic_writing\main.py --notes agent_04_academic_writing\sample_notes.md --field "plant science"
python agent_04_academic_writing\main.py --manuscript my_paper.md --field "medicine" --reviewers 2
```
## What this agent teaches
1. **Multi-persona simulation** — the same model plays three reviewers with different priorities.
2. **Critique → revision loops** — the classic "generator + critic" agent pattern.
3. **Guardrails against fabrication** — `[TO BE ADDED BY AUTHORS]` instead of invented facts; AI-use declaration appended automatically.
4. **Knowledge bases in code** — a small journal catalogue the model must choose from.
## Ideas to extend
- Live journal data (OpenAlex, Scimago), predatory-journal blacklist, Word/LaTeX export per journal template, Zotero/BibTeX, grammar pass, cover-letter generator.
---
**Codanics — Python ka Chilla: Build AI Agents**
Course: https://codanics.com/courses/python-ka-chilla-build-ai-agents/ · Website: https://codanics.com · YouTube: [@codanics](https://www.youtube.com/@codanics)
WhatsApp: https://whatsapp.com/channel/0029Va7nRDq3QxRzGqaQvS3r · Free course: https://www.youtube.com/watch?v=UE3o-7KRNXs&list=PLaQmdklCcX4019.2 Source files
19.2.1 agents.py
"""
agents.py — the four sub-agents of the Academic Writing & Publication Agent.
ManuscriptAgent : notes/results ─► IMRaD manuscript (Markdown)
JournalMatchingAgent : manuscript + catalogue ─► ranked journals (structured)
ReviewerSimulationAgent: manuscript ─► N reviewers with personas (structured)
RevisionAgent : manuscript + reviews ─► response letter + revised manuscript
Codanics — Python ka Chilla: Build AI Agents
https://codanics.com/courses/python-ka-chilla-build-ai-agents/
"""
from __future__ import annotations
from pydantic import BaseModel, Field
from journals import catalogue_text
from llm import ask, ask_structured
BRAND = ("Codanics — Python ka Chilla: Build AI Agents | "
"https://codanics.com/courses/python-ka-chilla-build-ai-agents/")
NO_FABRICATION = ("STRICT RULE: use only facts, numbers and references present in the input. If something is "
"missing, write '[TO BE ADDED BY AUTHORS]' instead of inventing it.")
AI_STATEMENT = ("\n\n## Declaration of AI assistance\n"
"An AI writing assistant (AI Academic Writing & Publication Agent) was used to draft and structure "
"this manuscript from the authors' notes and results. All content was reviewed, verified and "
"approved by the authors, who take full responsibility for the work.\n")
# ------------------------------------------------------------------- 1. Manuscript
class ManuscriptAgent:
SYSTEM = ("You are an experienced academic writer and editor. Write a complete manuscript in Markdown "
"with: Title; Authors placeholder; Abstract (250 words, structured); Keywords; 1 Introduction "
"(with the gap and objectives); 2 Materials and Methods; 3 Results; 4 Discussion; 5 Conclusion; "
"Acknowledgements placeholder; References (only those given). Clear, concise academic English "
"suitable for non-native authors. " + NO_FABRICATION)
def run(self, notes: str, field: str, references: str = "", echo: bool = False) -> str:
text = ask(self.SYSTEM,
f"Field: {field}\n\nAuthor notes and results:\n{notes}\n\nReferences available:\n"
f"{references or '(none provided)'}\n\nWrite the manuscript.",
max_tokens=32000, effort="high", echo=echo)
return text + AI_STATEMENT
# ------------------------------------------------------------- 2. Journal matching
class JournalMatch(BaseModel):
journal: str
scope_fit: int = Field(ge=1, le=10)
impact_band: str
open_access: str
estimated_apc_usd: int | None
acceptance_probability: str = Field(description="low | medium | high, with the main reason")
reason: str
tips_for_this_journal: str
class JournalRanking(BaseModel):
manuscript_summary: str
matches: list[JournalMatch] = Field(description="Top 5, best first")
avoid: list[str] = Field(description="Journals from the catalogue that are a poor fit, with a reason")
class JournalMatchingAgent:
SYSTEM = ("You are a journal-selection consultant. Score journals from the provided catalogue ONLY "
"(never invent journals). Balance scope fit, impact, cost, speed and realistic acceptance odds.")
def run(self, manuscript: str) -> JournalRanking:
return ask_structured(self.SYSTEM,
f"Journal catalogue:\n{catalogue_text()}\n\nManuscript:\n{manuscript[:20000]}",
JournalRanking, effort="medium")
# ----------------------------------------------------------- 3. Reviewer simulation
class ReviewComment(BaseModel):
number: int
section: str
comment: str
severity: str = Field(description="minor | major | critical")
class Review(BaseModel):
reviewer: str = Field(description="e.g. 'Reviewer 1 (methodologist)'")
summary: str
comments: list[ReviewComment]
recommendation: str = Field(description="accept | minor revision | major revision | reject")
class ReviewSet(BaseModel):
reviews: list[Review]
PERSONAS = [
"Reviewer 1 — a strict methodologist who focuses on validation, controls, sample size and reproducibility",
"Reviewer 2 — a domain expert who checks novelty, dataset selection, related work and interpretation",
"Reviewer 3 — a statistician/data scientist who checks tests, leakage, metrics and figures",
]
class ReviewerSimulationAgent:
SYSTEM = ("You simulate peer review for a scientific manuscript. Each reviewer has a distinct persona and "
"must give specific, numbered, actionable comments (quote the problematic sentence when possible). "
"Be tough but fair.")
def run(self, manuscript: str, n_reviewers: int = 3) -> ReviewSet:
personas = "\n".join(PERSONAS[:n_reviewers])
return ask_structured(self.SYSTEM,
f"Reviewer personas:\n{personas}\n\nManuscript:\n{manuscript[:30000]}\n\n"
f"Produce {n_reviewers} reviews.",
ReviewSet, effort="high")
# ----------------------------------------------------------------- 4. Revision
class RevisionAgent:
SYSTEM_LETTER = ("You write point-by-point response letters to reviewers. For EVERY comment: quote it, "
"respond politely and concretely, and state exactly what was changed and where "
"(section/paragraph). If a change is impossible, explain why respectfully. Markdown.")
SYSTEM_REVISE = ("You revise scientific manuscripts to address reviewer comments. Return the FULL revised "
"manuscript in Markdown. Mark changed passages by wrapping them in **bold**. "
+ NO_FABRICATION)
def letter(self, manuscript: str, reviews: ReviewSet, echo: bool = False) -> str:
text = ask(self.SYSTEM_LETTER,
f"Reviews:\n{reviews.model_dump_json(indent=1)}\n\nManuscript:\n{manuscript[:30000]}",
effort="high", echo=echo)
return text + f"\n\n---\n*Generated with the AI Academic Writing & Publication Agent — {BRAND}*\n"
def revise(self, manuscript: str, reviews: ReviewSet, echo: bool = False) -> str:
return ask(self.SYSTEM_REVISE,
f"Reviews:\n{reviews.model_dump_json(indent=1)}\n\nOriginal manuscript:\n{manuscript}",
max_tokens=32000, effort="high", echo=echo)19.2.2 journals.py
"""
journals.py — a small, editable journal knowledge base for the Journal Matching Agent.
In production replace this with live data (OpenAlex / Scimago / DOAJ APIs) and a predatory-journal
blacklist. Impact bands are indicative only.
Codanics — Python ka Chilla: Build AI Agents
https://codanics.com/courses/python-ka-chilla-build-ai-agents/
"""
from __future__ import annotations
from pydantic import BaseModel
class Journal(BaseModel):
name: str
publisher: str
scope: str
fields: list[str]
impact_band: str # "very high" | "high" | "medium" | "emerging"
open_access: str # "gold" | "hybrid" | "subscription"
apc_usd: int | None # article processing charge if OA
review_weeks: int # typical first-decision time
acceptance_rate: str # rough band
JOURNALS: list[Journal] = [
Journal(name="Computers and Electronics in Agriculture", publisher="Elsevier",
scope="Computing, sensing and AI applied to agriculture and food production",
fields=["agriculture", "plant science", "AI", "remote sensing"], impact_band="high",
open_access="hybrid", apc_usd=3500, review_weeks=8, acceptance_rate="~25%"),
Journal(name="Plant Phenomics", publisher="Science Partner Journals / AAAS",
scope="High-throughput plant phenotyping, imaging, sensors and analytics",
fields=["plant science", "phenotyping", "AI"], impact_band="high",
open_access="gold", apc_usd=3000, review_weeks=6, acceptance_rate="~30%"),
Journal(name="Frontiers in Plant Science", publisher="Frontiers",
scope="All areas of plant biology including stress physiology and computational approaches",
fields=["plant science", "biology", "stress"], impact_band="medium",
open_access="gold", apc_usd=3300, review_weeks=8, acceptance_rate="~50%"),
Journal(name="Remote Sensing", publisher="MDPI",
scope="Remote-sensing science and applications including UAV and hyperspectral imaging",
fields=["remote sensing", "agriculture", "AI"], impact_band="medium",
open_access="gold", apc_usd=2700, review_weeks=4, acceptance_rate="~45%"),
Journal(name="Scientific Reports", publisher="Springer Nature",
scope="Multidisciplinary, technically sound research in natural sciences",
fields=["multidisciplinary", "biology", "AI", "medicine"], impact_band="medium",
open_access="gold", apc_usd=2690, review_weeks=10, acceptance_rate="~45%"),
Journal(name="Nature Machine Intelligence", publisher="Springer Nature",
scope="High-impact AI/ML research with broad scientific significance",
fields=["AI", "machine learning", "multidisciplinary"], impact_band="very high",
open_access="hybrid", apc_usd=12000, review_weeks=12, acceptance_rate="<10%"),
Journal(name="Bioinformatics", publisher="Oxford University Press",
scope="Computational methods and tools for biological data analysis",
fields=["bioinformatics", "genomics", "biology"], impact_band="high",
open_access="hybrid", apc_usd=3500, review_weeks=8, acceptance_rate="~20%"),
Journal(name="BMC Bioinformatics", publisher="Springer Nature",
scope="Bioinformatics methods, databases and applications",
fields=["bioinformatics", "genomics"], impact_band="medium",
open_access="gold", apc_usd=2790, review_weeks=10, acceptance_rate="~35%"),
Journal(name="Theoretical and Applied Genetics", publisher="Springer",
scope="Plant genetics, genomics and breeding",
fields=["genetics", "plant science", "breeding"], impact_band="high",
open_access="hybrid", apc_usd=3690, review_weeks=8, acceptance_rate="~25%"),
Journal(name="Journal of Medical Internet Research", publisher="JMIR Publications",
scope="Digital health, AI in medicine, eHealth",
fields=["medicine", "digital health", "AI"], impact_band="high",
open_access="gold", apc_usd=3300, review_weeks=6, acceptance_rate="~25%"),
Journal(name="IEEE Access", publisher="IEEE",
scope="Multidisciplinary engineering and computer science, rapid publication",
fields=["engineering", "computer science", "AI"], impact_band="medium",
open_access="gold", apc_usd=1995, review_weeks=4, acceptance_rate="~30%"),
Journal(name="PLOS ONE", publisher="PLOS",
scope="Multidisciplinary; rigorous methodology rather than perceived impact",
fields=["multidisciplinary", "biology", "medicine", "social science"], impact_band="medium",
open_access="gold", apc_usd=2290, review_weeks=10, acceptance_rate="~45%"),
Journal(name="Expert Systems with Applications", publisher="Elsevier",
scope="Applied AI and intelligent systems across domains",
fields=["AI", "computer science", "applications"], impact_band="high",
open_access="hybrid", apc_usd=3600, review_weeks=10, acceptance_rate="~20%"),
Journal(name="Agricultural Water Management", publisher="Elsevier",
scope="Water use, irrigation, drought and crop-water relations",
fields=["agriculture", "water", "drought"], impact_band="high",
open_access="hybrid", apc_usd=3500, review_weeks=8, acceptance_rate="~25%"),
Journal(name="Heliyon", publisher="Cell Press / Elsevier",
scope="Multidisciplinary open-access journal for technically sound research",
fields=["multidisciplinary"], impact_band="emerging",
open_access="gold", apc_usd=2000, review_weeks=6, acceptance_rate="~50%"),
]
def catalogue_text() -> str:
return "\n".join(j.model_dump_json() for j in JOURNALS)19.2.3 llm.py
"""
llm.py — a thin wrapper around the Anthropic Claude API used by this agent.
Two functions are all you need:
ask(system, user) -> free text (streamed)
ask_structured(system, user, Model) -> a validated Pydantic object (JSON structured output)
Why a wrapper? Every agent in this project talks to the model the same way. Keeping the
API details in ONE file per agent means you can swap the model or provider in one place.
Codanics — Python ka Chilla: Build AI Agents
https://codanics.com/courses/python-ka-chilla-build-ai-agents/
"""
from __future__ import annotations
import os
from typing import Type, TypeVar
import anthropic
from dotenv import load_dotenv
from pydantic import BaseModel
load_dotenv() # reads ANTHROPIC_API_KEY / CLAUDE_MODEL from a .env file if present
MODEL = os.getenv("CLAUDE_MODEL", "claude-opus-5")
T = TypeVar("T", bound=BaseModel)
_client: anthropic.Anthropic | None = None
def client() -> anthropic.Anthropic:
"""Lazily create one shared client (credentials come from the environment)."""
global _client
if _client is None:
_client = anthropic.Anthropic()
return _client
def _check(msg: anthropic.types.Message) -> None:
if msg.stop_reason == "refusal":
detail = getattr(msg, "stop_details", None)
raise RuntimeError(f"The model refused this request: {detail}")
if msg.stop_reason == "max_tokens":
raise RuntimeError("Output was cut off (max_tokens). Increase max_tokens and retry.")
def _text(msg: anthropic.types.Message) -> str:
return "".join(b.text for b in msg.content if b.type == "text")
def ask(system: str, user: str, *, max_tokens: int = 16000, effort: str = "medium",
echo: bool = False) -> str:
"""Free-text generation. Streams so long outputs never hit HTTP timeouts."""
with client().messages.stream(
model=MODEL,
max_tokens=max_tokens,
system=system,
messages=[{"role": "user", "content": user}],
thinking={"type": "adaptive"},
output_config={"effort": effort},
) as stream:
if echo:
for chunk in stream.text_stream:
print(chunk, end="", flush=True)
print()
msg = stream.get_final_message()
_check(msg)
return _text(msg)
def ask_structured(system: str, user: str, model_cls: Type[T], *, max_tokens: int = 16000,
effort: str = "medium") -> T:
"""Structured generation: the SDK converts the Pydantic model to a JSON schema, the API
constrains the output to it, and `parsed_output` is a validated instance of `model_cls`."""
with client().messages.stream(
model=MODEL,
max_tokens=max_tokens,
system=system,
messages=[{"role": "user", "content": user}],
thinking={"type": "adaptive"},
output_config={"effort": effort},
output_format=model_cls,
) as stream:
msg = stream.get_final_message()
_check(msg)
if msg.parsed_output is None: # should not happen with a constrained schema; be defensive
return model_cls.model_validate_json(_text(msg))
return msg.parsed_output19.2.4 main.py
"""
AI Academic Writing & Publication Agent — "From Data to Manuscript"
===================================================================
notes ─► manuscript ─► journal matching ─► reviewer simulation ─► response letter + revised manuscript
Usage
python main.py --notes sample_notes.md --field "plant science"
python main.py --notes my_notes.md --field "medicine" --reviewers 2 --references refs.md
python main.py --manuscript existing_paper.md --field "AI" # skip drafting; review an existing paper
Outputs: outputs/<slug>/ manuscript_v1.md, journal_matches.json, reviews.json, response_letter.md, manuscript_v2.md
Codanics — Python ka Chilla: Build AI Agents
https://codanics.com/courses/python-ka-chilla-build-ai-agents/
"""
from __future__ import annotations
import sys
from pathlib import Path
import typer
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from slugify import slugify
sys.path.insert(0, str(Path(__file__).parent))
if hasattr(sys.stdout, "reconfigure"): # Windows consoles default to cp1252; we print → ✓ etc.
sys.stdout.reconfigure(encoding="utf-8")
from agents import (JournalMatchingAgent, ManuscriptAgent, ReviewerSimulationAgent, # noqa: E402
RevisionAgent)
app = typer.Typer(add_completion=False)
console = Console()
OUT_DIR = Path(__file__).parent / "outputs"
BANNER = """[bold cyan]AI Academic Writing & Publication Agent[/] — From Data to Manuscript
[dim]Codanics · Python ka Chilla: Build AI Agents · https://codanics.com/courses/python-ka-chilla-build-ai-agents/
YouTube @codanics · WhatsApp https://whatsapp.com/channel/0029Va7nRDq3QxRzGqaQvS3r[/]"""
def save(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
console.print(f" [green]saved[/] {path.name}")
@app.command()
def run(
notes: Path = typer.Option(None, help="Markdown/text file with study notes and results"),
manuscript: Path = typer.Option(None, help="Existing manuscript (.md) to review instead of drafting"),
references: Path = typer.Option(None, help="Optional reference list file the writer may cite"),
field: str = typer.Option("multidisciplinary", help="Research field, e.g. 'plant science'"),
reviewers: int = typer.Option(3, min=1, max=3),
echo: bool = typer.Option(True),
):
console.print(Panel(BANNER, expand=False))
if not notes and not manuscript:
raise typer.BadParameter("Provide --notes or --manuscript")
src = manuscript or notes
out = OUT_DIR / slugify(src.stem)[:60]
refs = references.read_text(encoding="utf-8") if references else ""
# 1) Manuscript ---------------------------------------------------------------
console.rule("[bold]1. Manuscript")
if manuscript:
ms = manuscript.read_text(encoding="utf-8")
console.print(f" using existing manuscript {manuscript}")
else:
ms = ManuscriptAgent().run(notes.read_text(encoding="utf-8"), field, refs, echo=echo)
save(out / "manuscript_v1.md", ms)
# 2) Journal matching ---------------------------------------------------------
console.rule("[bold]2. Journal matching")
ranking = JournalMatchingAgent().run(ms)
t = Table(title="Recommended journals")
for c in ("journal", "scope fit", "impact", "OA", "APC $", "acceptance"):
t.add_column(c)
for m in ranking.matches:
t.add_row(m.journal, str(m.scope_fit), m.impact_band, m.open_access,
str(m.estimated_apc_usd or "-"), m.acceptance_probability)
console.print(t)
save(out / "journal_matches.json", ranking.model_dump_json(indent=2))
# 3) Reviewer simulation ------------------------------------------------------
console.rule(f"[bold]3. Simulated peer review ({reviewers} reviewers)")
reviews = ReviewerSimulationAgent().run(ms, reviewers)
for r in reviews.reviews:
console.print(f" [bold]{r.reviewer}[/] → {r.recommendation}")
for c in r.comments[:4]:
console.print(f" {c.number}. [{c.severity}] {c.comment[:140]}")
save(out / "reviews.json", reviews.model_dump_json(indent=2))
# 4) Revision -----------------------------------------------------------------
console.rule("[bold]4. Response letter")
rev = RevisionAgent()
save(out / "response_letter.md", rev.letter(ms, reviews, echo=echo))
console.rule("[bold]5. Revised manuscript")
save(out / "manuscript_v2.md", rev.revise(ms, reviews, echo=False))
console.print(Panel(f"Done. Outputs in [bold]{out}[/]", style="green"))
if __name__ == "__main__":
app()19.2.5 sample_notes.md
# Study notes — Multimodal AI for drought-stress prediction in wheat
## Aim
Predict drought stress in bread wheat (Triticum aestivum) 7 days before visible symptoms by fusing
UAV RGB imagery, canopy temperature and 2,000 SNP markers.
## Setup
- Field trial, Faisalabad, Pakistan, 2024–25 season.
- 120 genotypes × 2 water regimes (well-watered vs. drought from booting stage) × 3 replicates = 720 plots.
- UAV flights every 5 days (RGB + thermal); soil moisture sensors; SNP genotyping (2,000 markers).
- Ground truth: leaf relative water content (RWC) and visual stress score.
## Models compared
- RGB-only CNN (ResNet-18)
- Thermal + weather gradient boosting
- Genomic-only GBLUP
- **Proposed**: late-fusion model (CNN embedding + thermal features + genomic PCs) with gradient boosting
## Key results
- Proposed fusion model: accuracy 91.3%, F1 0.90, AUC 0.95 (5-fold CV, genotype-grouped folds).
- RGB-only: accuracy 84.1%; thermal+weather: 86.7%; genomic-only: 71.2%.
- Fusion detected stress on average 6.4 days before visible symptoms (vs 3.1 days RGB-only).
- SHAP: canopy temperature depression and two SNP clusters on chromosomes 2B and 5A were the most important features.
- Model trained on 2024 data generalised to 2025 with accuracy 87.9%.
## Limitations noted by the team
- Single location; two seasons.
- Genotyping cost limits deployment for smallholders.
- Thermal imagery sensitive to time of day.
## References we want to cite (authors' own list)
- Khan A, Ali B (2023). Deep learning for drought stress detection in wheat using RGB imagery. Computers and Electronics in Agriculture.
- Zhang C (2022). Hyperspectral indices for early drought detection in cereals: a review. Remote Sensing.
- Rehman D, Fatima E (2024). Genomic prediction of drought tolerance in wheat landraces. Theoretical and Applied Genetics.