PhDScout / agent /interactive_review.py
HipFil98's picture
refactor: OOP redesign — JobAgent orchestrator, shared utils, no global state mutation
7419404
"""Interactive human-in-the-loop review component.
For each qualifying position the agent has analysed, this module:
1. Displays full position details
2. Shows tailoring hints as a checklist
3. Shows the cover letter draft
4. Prompts the user to Approve / Edit / Regenerate / Skip / Quit
If the user chooses Edit, the cover letter is opened in $EDITOR (falling back to
nano on Linux/Mac or notepad on Windows). If no editor is available, inline
text entry is offered as a last resort.
"""
from __future__ import annotations
import os
import subprocess
import tempfile
from typing import Any, TypedDict
from rich.console import Console
from rich.markdown import Markdown
from rich.panel import Panel
from rich.prompt import Prompt
from rich.table import Table
from rich import box
# ---------------------------------------------------------------------------
# Type definitions
# ---------------------------------------------------------------------------
class ReviewResult(TypedDict):
decision: str # "approved" | "skipped" | "quit"
cover_letter: str # final (possibly edited) cover letter
notes: str # optional user notes
# ---------------------------------------------------------------------------
# Display helpers
# ---------------------------------------------------------------------------
def _score_color(score: int) -> str:
if score >= 80:
return "green"
elif score >= 60:
return "yellow"
return "red"
def _rec_color(rec: str) -> str:
return {"apply": "green", "consider": "yellow", "skip": "red"}.get(rec, "white")
def _show_position_panel(job: dict[str, Any], match: dict[str, Any], console: Console) -> None:
"""Render a rich panel with the position details and match analysis."""
score = match.get("match_score", 0)
rec = match.get("recommendation", "")
table = Table(box=box.SIMPLE, show_header=False, padding=(0, 1))
table.add_column("Field", style="cyan", no_wrap=True)
table.add_column("Value", style="white")
table.add_row("Title", job.get("title") or "N/A")
table.add_row("Institution", job.get("institution", job.get("company", "N/A")))
table.add_row("Location", job.get("location") or "N/A")
table.add_row("Type", job.get("type") or "N/A")
table.add_row("Deadline", job.get("deadline") or "Not specified")
table.add_row("URL", job.get("url") or "N/A")
table.add_row("Source", job.get("source") or "N/A")
if job.get("email"):
table.add_row("Contact email", job["email"])
table.add_row(
"Match score",
f"[{_score_color(score)}]{score}/100[/{_score_color(score)}]",
)
table.add_row(
"Recommendation",
f"[{_rec_color(rec)}]{rec.upper()}[/{_rec_color(rec)}]",
)
if match.get("why_good_fit"):
table.add_row("Why good fit", match["why_good_fit"])
if match.get("concerns"):
table.add_row("Concerns", f"[yellow]{match['concerns']}[/yellow]")
matching = match.get("matching_areas") or []
if matching:
table.add_row("Matching areas", ", ".join(matching))
missing = match.get("missing_requirements") or []
if missing:
table.add_row("Missing", "[red]" + ", ".join(missing) + "[/red]")
console.print(Panel(table, title="[bold]Position Details[/bold]", border_style="blue"))
def _show_tailoring_panel(hints: dict[str, Any], console: Console) -> None:
"""Render CV tailoring hints as a checklist panel."""
lines: list[str] = []
if hints.get("headline_suggestion"):
lines.append("[bold cyan]Profile summary tweak:[/bold cyan]")
lines.append(f" {hints['headline_suggestion']}")
lines.append("")
if hints.get("research_alignment"):
lines.append("[bold cyan]How to frame your research interests:[/bold cyan]")
lines.append(f" {hints['research_alignment']}")
lines.append("")
skills = hints.get("skills_to_highlight") or []
if skills:
lines.append("[bold cyan]Skills to emphasise:[/bold cyan]")
for s in skills:
lines.append(f" [ ] {s}")
lines.append("")
experience = hints.get("experience_to_emphasize") or []
if experience:
lines.append("[bold cyan]Experience entries to highlight:[/bold cyan]")
for e in experience:
lines.append(f" [ ] {e}")
lines.append("")
keywords = hints.get("keywords_to_add") or []
if keywords:
lines.append("[bold cyan]Keywords to add to your CV:[/bold cyan]")
lines.append(" " + ", ".join(keywords))
lines.append("")
order = hints.get("suggested_order") or []
if order:
lines.append("[bold cyan]Suggested CV section order:[/bold cyan]")
lines.append(" " + " → ".join(order))
content = "\n".join(lines) if lines else "[dim]No tailoring hints generated.[/dim]"
console.print(Panel(content, title="[bold]CV Tailoring Hints[/bold]", border_style="yellow"))
def _show_cover_letter_panel(cover_letter: str, console: Console) -> None:
"""Render the cover letter draft in a panel."""
# Try to render as Markdown for nicer display
try:
rendered: Any = Markdown(cover_letter)
except Exception:
rendered = cover_letter
console.print(Panel(rendered, title="[bold]Cover Letter Draft[/bold]", border_style="green"))
# ---------------------------------------------------------------------------
# Editor helper
# ---------------------------------------------------------------------------
def _open_in_editor(content: str, console: Console) -> str:
"""Open content in the user's $EDITOR and return the edited text.
Falls back to nano → vi → notepad (Windows).
If no editor is found, offers inline text entry.
"""
# Determine editor
editor = os.environ.get("EDITOR", "")
if not editor:
# Try common fallbacks
for fallback in ("nano", "vi", "notepad"):
if _command_exists(fallback):
editor = fallback
break
if editor:
# Write to temp file, open editor, read back
try:
with tempfile.NamedTemporaryFile(
suffix=".txt", mode="w", delete=False, encoding="utf-8"
) as tmp:
tmp.write(content)
tmpfile = tmp.name
console.print(
f"[dim]Opening in {editor} — save and close the editor to continue.[/dim]"
)
subprocess.call([editor, tmpfile])
with open(tmpfile, encoding="utf-8") as f:
edited = f.read()
os.unlink(tmpfile)
return edited
except Exception as exc:
console.print(f"[yellow]Editor launch failed: {exc}. Falling back to inline entry.[/yellow]")
# Inline fallback: show the text, then ask for a replacement
console.print("\n[yellow]No editor available. Current cover letter:[/yellow]")
console.print(content)
console.print(
"\n[yellow]Paste your edited version below. "
"Enter a single dot '.' on a blank line when done:[/yellow]"
)
typed_lines: list[str] = []
while True:
try:
line = input()
except EOFError:
break
if line.strip() == ".":
break
typed_lines.append(line)
inline_text = "\n".join(typed_lines).strip()
return inline_text if inline_text else content
def _command_exists(cmd: str) -> bool:
"""Return True if a command is available on PATH."""
import shutil
return shutil.which(cmd) is not None
# ---------------------------------------------------------------------------
# Main review session
# ---------------------------------------------------------------------------
class ReviewSession:
"""Manages the interactive review of a single job application."""
def __init__(self, llm_model: str | None = None) -> None:
# Store the model so we can regenerate cover letters
self._model = llm_model
def review_position(
self,
job: dict[str, Any],
match: dict[str, Any],
tailoring_hints: dict[str, Any],
cover_letter: str,
console: Console,
) -> ReviewResult:
"""Display position details, hints, and cover letter; collect user decision.
Args:
job: Job listing dict.
match: MatchResult dict.
tailoring_hints: TailoringHints dict.
cover_letter: Generated cover letter draft.
console: Rich Console for output.
Returns:
ReviewResult with decision ("approved" | "skipped" | "quit"),
final cover letter, and optional notes.
"""
# --- Display phase ---
console.rule(
f"[bold]{job.get('title', 'Unknown')} "
f"@ {job.get('institution', job.get('company', 'Unknown'))}[/bold]"
)
_show_position_panel(job, match, console)
_show_tailoring_panel(tailoring_hints, console)
_show_cover_letter_panel(cover_letter, console)
current_letter = cover_letter
# --- Decision loop ---
while True:
console.print("")
console.print(
" [green]\\[A]pprove[/green] "
"[yellow]\\[E]dit cover letter[/yellow] "
"[blue]\\[R]egenerate[/blue] "
"[dim]\\[S]kip[/dim] "
"[red]\\[Q]uit[/red]"
)
choice = Prompt.ask(
"[bold cyan]Action[/bold cyan]",
choices=["a", "e", "r", "s", "q"],
default="s",
show_choices=False,
show_default=False,
console=console,
).lower()
if choice == "a":
notes = Prompt.ask(
"Optional notes (press Enter to skip)",
default="",
console=console,
)
return ReviewResult(
decision="approved",
cover_letter=current_letter,
notes=notes,
)
elif choice == "e":
console.print("[dim]Opening editor...[/dim]")
current_letter = _open_in_editor(current_letter, console)
console.print("[green]Cover letter updated.[/green]")
_show_cover_letter_panel(current_letter, console)
elif choice == "r":
console.print("[dim]Regenerating cover letter...[/dim]")
current_letter = self._regenerate(job, cover_letter, console)
_show_cover_letter_panel(current_letter, console)
elif choice == "s":
return ReviewResult(
decision="skipped",
cover_letter=current_letter,
notes="",
)
elif choice == "q":
return ReviewResult(
decision="quit",
cover_letter=current_letter,
notes="",
)
def _regenerate(
self,
job: dict[str, Any],
original_letter: str,
console: Console,
) -> str:
try:
from agent.llm_client import LLMClient
from agent.cover_letter import CoverLetterWriter
profile_text = getattr(self, "_profile_text", "")
llm = LLMClient(model=self._model)
new_letter = CoverLetterWriter(llm).generate(job, profile_text, regenerate=True)
return new_letter
except Exception as exc:
console.print(f"[red]Regeneration failed: {exc}[/red]")
return original_letter
def set_profile(self, profile: dict[str, Any], profile_text: str) -> None:
"""Provide the CV profile for regeneration use."""
self._profile = profile
self._profile_text = profile_text