grad-agent 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- grad_agent/__init__.py +0 -0
- grad_agent/catalog_sync.py +174 -0
- grad_agent/cli.py +184 -0
- grad_agent/config.py +114 -0
- grad_agent/daily_run.py +224 -0
- grad_agent/drafters/__init__.py +0 -0
- grad_agent/drafters/article.py +57 -0
- grad_agent/drafters/cold_email.py +96 -0
- grad_agent/drafters/sop_latex.py +129 -0
- grad_agent/hook.py +136 -0
- grad_agent/lor.py +62 -0
- grad_agent/mark_promoted.py +32 -0
- grad_agent/outputs/__init__.py +0 -0
- grad_agent/outputs/mailer.py +45 -0
- grad_agent/outputs/portfolio_api.py +93 -0
- grad_agent/outreach_log.py +96 -0
- grad_agent/profile_pdf.py +44 -0
- grad_agent/programs.py +59 -0
- grad_agent/projects.py +160 -0
- grad_agent/server.py +284 -0
- grad_agent/sources/__init__.py +0 -0
- grad_agent/sources/arxiv.py +35 -0
- grad_agent/sources/github.py +63 -0
- grad_agent/sources/hf.py +49 -0
- grad_agent/sources/openreview.py +31 -0
- grad_agent/sources/recruiting.py +43 -0
- grad_agent/sources/semantic_scholar.py +75 -0
- grad_agent/sources/websearch.py +30 -0
- grad_agent/templates/env.example +21 -0
- grad_agent/templates/profile.example.yaml +46 -0
- grad_agent/templates/programs.example.yaml +115 -0
- grad_agent/tracker.py +76 -0
- grad_agent-0.1.1.dist-info/METADATA +192 -0
- grad_agent-0.1.1.dist-info/RECORD +36 -0
- grad_agent-0.1.1.dist-info/WHEEL +4 -0
- grad_agent-0.1.1.dist-info/entry_points.txt +2 -0
grad_agent/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Extend the project catalog from GitHub + HuggingFace + local NINTE dirs.
|
|
2
|
+
|
|
3
|
+
Writes/updates `catalog.json` in the grad-agent directory. Each entry has the
|
|
4
|
+
same shape as the seed `PROJECTS` list in projects.py:
|
|
5
|
+
{name, pitch, link, tags}
|
|
6
|
+
Claude Haiku distils each source (repo, model, local README) into the pitch
|
|
7
|
+
and tags. Existing entries are updated in place; new ones appended.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
import os, json, re, time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import config as _cfg
|
|
14
|
+
_cfg.load_env_file()
|
|
15
|
+
|
|
16
|
+
from .sources import github as gh
|
|
17
|
+
from .sources import hf
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import anthropic
|
|
21
|
+
except Exception:
|
|
22
|
+
anthropic = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _catalog_path() -> Path:
|
|
26
|
+
return _cfg.data_dir() / "catalog.json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _projects_dir() -> Path:
|
|
30
|
+
prof = _cfg.load_profile()
|
|
31
|
+
if prof.projects_dir:
|
|
32
|
+
return Path(prof.projects_dir).expanduser()
|
|
33
|
+
return Path(os.environ.get("NINTE_ROOT", str(Path.home())))
|
|
34
|
+
|
|
35
|
+
# Skip these local dirs when scanning _projects_dir()
|
|
36
|
+
SKIP_LOCAL = {"GRAD", "portfolio", "SNAPSHOT", "Kwabena.zip"}
|
|
37
|
+
|
|
38
|
+
SYSTEM = (
|
|
39
|
+
"You extract a compact JSON record from a project description. Return a "
|
|
40
|
+
"strict JSON object with keys: pitch (one sentence, 25 to 45 words, in "
|
|
41
|
+
"Kwabena's voice, direct and grounded in the actual work described, no "
|
|
42
|
+
"cliches, no dashes), tags (a list of 5 to 12 short lowercase phrases "
|
|
43
|
+
"describing the technical area, methods, and domain, e.g. 'nlp', 'llm', "
|
|
44
|
+
"'african languages', 'yolo', 'time series'). Nothing else. No prose "
|
|
45
|
+
"outside the JSON."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _distil(name: str, kind: str, description: str, extra: str = "",
|
|
50
|
+
model: str = "claude-haiku-4-5-20251001") -> dict:
|
|
51
|
+
if anthropic is None or not os.environ.get("ANTHROPIC_API_KEY"):
|
|
52
|
+
# Heuristic: pitch = first 40 words of description, tags = topics/tags
|
|
53
|
+
pitch = " ".join((description or f"{kind} named {name}").split()[:40])
|
|
54
|
+
return {"pitch": pitch, "tags": []}
|
|
55
|
+
client = anthropic.Anthropic()
|
|
56
|
+
user = (
|
|
57
|
+
f"KIND: {kind}\nNAME: {name}\nDESCRIPTION: {description}\n\n"
|
|
58
|
+
f"EXTRA CONTEXT (README or metadata):\n{extra[:3000]}\n\n"
|
|
59
|
+
f"Return the JSON now."
|
|
60
|
+
)
|
|
61
|
+
m = client.messages.create(
|
|
62
|
+
model=model, max_tokens=400, system=SYSTEM,
|
|
63
|
+
messages=[{"role": "user", "content": user}],
|
|
64
|
+
)
|
|
65
|
+
raw = m.content[0].text.strip()
|
|
66
|
+
# tolerate ```json fences
|
|
67
|
+
raw = re.sub(r"^```(?:json)?", "", raw).rstrip("`").strip()
|
|
68
|
+
try:
|
|
69
|
+
obj = json.loads(raw)
|
|
70
|
+
obj["pitch"] = obj.get("pitch", "").replace("—", ", ").replace("–", ", ")
|
|
71
|
+
return {"pitch": obj.get("pitch", ""), "tags": obj.get("tags", []) or []}
|
|
72
|
+
except Exception:
|
|
73
|
+
return {"pitch": (description or "")[:200], "tags": []}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _load() -> dict:
|
|
77
|
+
if _catalog_path().exists():
|
|
78
|
+
return json.loads(_catalog_path().read_text())
|
|
79
|
+
return {"projects": {}, "last_synced": None}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _save(cat: dict) -> None:
|
|
83
|
+
cat["last_synced"] = time.strftime("%Y-%m-%d %H:%M")
|
|
84
|
+
_catalog_path().write_text(json.dumps(cat, indent=2))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _upsert(cat: dict, key: str, entry: dict) -> None:
|
|
88
|
+
cat["projects"][key] = entry
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def sync_github() -> int:
|
|
92
|
+
cat = _load(); added = 0
|
|
93
|
+
for r in gh.list_repos():
|
|
94
|
+
if r["fork"] or r["archived"]: continue
|
|
95
|
+
readme = gh.fetch_readme(r["full_name"])
|
|
96
|
+
d = _distil(
|
|
97
|
+
name=r["name"],
|
|
98
|
+
kind="github repo",
|
|
99
|
+
description=r["description"] or "",
|
|
100
|
+
extra=(readme or "") + "\nTopics: " + ", ".join(r["topics"]),
|
|
101
|
+
)
|
|
102
|
+
entry = {
|
|
103
|
+
"name": r["name"],
|
|
104
|
+
"pitch": d["pitch"] or (r["description"] or r["name"]),
|
|
105
|
+
"link": r["html_url"],
|
|
106
|
+
"tags": list(dict.fromkeys(d["tags"] + r["topics"])),
|
|
107
|
+
"source": "github",
|
|
108
|
+
"language": r["language"],
|
|
109
|
+
"stars": r["stars"],
|
|
110
|
+
}
|
|
111
|
+
_upsert(cat, f"gh:{r['full_name']}", entry); added += 1
|
|
112
|
+
_save(cat); return added
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def sync_hf() -> int:
|
|
116
|
+
cat = _load(); added = 0
|
|
117
|
+
for m in hf.list_models() + hf.list_datasets():
|
|
118
|
+
readme = hf.fetch_readme(m["id"], kind=m["kind"] + "s")
|
|
119
|
+
d = _distil(
|
|
120
|
+
name=m["id"],
|
|
121
|
+
kind=f"huggingface {m['kind']}",
|
|
122
|
+
description=(m.get("pipeline_tag") or "") + " " + ", ".join(m["tags"][:10]),
|
|
123
|
+
extra=readme,
|
|
124
|
+
)
|
|
125
|
+
entry = {
|
|
126
|
+
"name": m["id"],
|
|
127
|
+
"pitch": d["pitch"] or f"{m['kind']} at {m['id']}",
|
|
128
|
+
"link": m["url"],
|
|
129
|
+
"tags": list(dict.fromkeys(d["tags"] + m["tags"])),
|
|
130
|
+
"source": "huggingface",
|
|
131
|
+
"downloads": m["downloads"],
|
|
132
|
+
"likes": m["likes"],
|
|
133
|
+
}
|
|
134
|
+
_upsert(cat, f"hf:{m['kind']}:{m['id']}", entry); added += 1
|
|
135
|
+
_save(cat); return added
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def sync_local() -> int:
|
|
139
|
+
cat = _load(); added = 0
|
|
140
|
+
for child in _projects_dir().iterdir():
|
|
141
|
+
if not child.is_dir() or child.name in SKIP_LOCAL: continue
|
|
142
|
+
readme_text = ""
|
|
143
|
+
for cand in ("README.md", "Readme.md", "readme.md", "README.txt"):
|
|
144
|
+
p = child / cand
|
|
145
|
+
if p.exists():
|
|
146
|
+
readme_text = p.read_text(errors="ignore")[:4000]; break
|
|
147
|
+
if not readme_text and not any((child / c).exists() for c in ("package.json", "requirements.txt")):
|
|
148
|
+
continue
|
|
149
|
+
d = _distil(
|
|
150
|
+
name=child.name, kind="local project directory",
|
|
151
|
+
description="", extra=readme_text,
|
|
152
|
+
)
|
|
153
|
+
entry = {
|
|
154
|
+
"name": child.name,
|
|
155
|
+
"pitch": d["pitch"] or child.name,
|
|
156
|
+
"link": str(child),
|
|
157
|
+
"tags": d["tags"],
|
|
158
|
+
"source": "local",
|
|
159
|
+
}
|
|
160
|
+
_upsert(cat, f"local:{child.name}", entry); added += 1
|
|
161
|
+
_save(cat); return added
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def sync_all() -> dict:
|
|
165
|
+
return {
|
|
166
|
+
"github": sync_github(),
|
|
167
|
+
"huggingface": sync_hf(),
|
|
168
|
+
"local": sync_local(),
|
|
169
|
+
"catalog_path": str(_catalog_path()),
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
if __name__ == "__main__":
|
|
174
|
+
print(sync_all())
|
grad_agent/cli.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""grad-agent CLI.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
init Interactive setup: writes ~/.grad-agent/profile.yaml + .env
|
|
5
|
+
server Start the MCP stdio server (for `claude mcp add`).
|
|
6
|
+
run Run today's outreach batch and email the review inbox.
|
|
7
|
+
sync Sync catalog from GitHub + HuggingFace + local projects_dir.
|
|
8
|
+
register-claude Print the `claude mcp add` command tailored to this install.
|
|
9
|
+
path Print resolved GRAD_AGENT_HOME.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
import argparse
|
|
13
|
+
import shutil
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import config
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _prompt(label: str, default: str = "") -> str:
|
|
21
|
+
suffix = f" [{default}]" if default else ""
|
|
22
|
+
v = input(f"{label}{suffix}: ").strip()
|
|
23
|
+
return v or default
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
27
|
+
home = config.ensure_home()
|
|
28
|
+
print(f"Setting up grad-agent at {home}\n")
|
|
29
|
+
|
|
30
|
+
tmpl_dir = config.TEMPLATES
|
|
31
|
+
profile_dst = config.profile_path()
|
|
32
|
+
env_dst = config.env_path()
|
|
33
|
+
programs_dst = config.programs_path()
|
|
34
|
+
|
|
35
|
+
if profile_dst.exists() and not args.force:
|
|
36
|
+
print(f"profile.yaml already exists at {profile_dst}. Use --force to overwrite.")
|
|
37
|
+
else:
|
|
38
|
+
shutil.copy(tmpl_dir / "profile.example.yaml", profile_dst)
|
|
39
|
+
print(f"wrote {profile_dst}")
|
|
40
|
+
|
|
41
|
+
if not programs_dst.exists() or args.force:
|
|
42
|
+
shutil.copy(tmpl_dir / "programs.example.yaml", programs_dst)
|
|
43
|
+
print(f"wrote {programs_dst}")
|
|
44
|
+
|
|
45
|
+
if env_dst.exists() and not args.force:
|
|
46
|
+
print(f".env already exists at {env_dst}. Use --force to overwrite.")
|
|
47
|
+
else:
|
|
48
|
+
shutil.copy(tmpl_dir / "env.example", env_dst)
|
|
49
|
+
print(f"wrote {env_dst}")
|
|
50
|
+
|
|
51
|
+
if args.interactive:
|
|
52
|
+
print("\nQuick setup, press Enter to keep defaults or fill later:\n")
|
|
53
|
+
import yaml
|
|
54
|
+
prof = yaml.safe_load(profile_dst.read_text()) or {}
|
|
55
|
+
prof["name"] = _prompt("Full name", prof.get("name", ""))
|
|
56
|
+
prof["identity_line"] = _prompt(
|
|
57
|
+
'One-line identity (e.g. "a Computer Science graduate from KNUST")',
|
|
58
|
+
prof.get("identity_line", ""),
|
|
59
|
+
)
|
|
60
|
+
prof["portfolio"] = _prompt("Portfolio URL", prof.get("portfolio", ""))
|
|
61
|
+
prof["cv_path"] = _prompt("Absolute path to CV pdf", prof.get("cv_path", ""))
|
|
62
|
+
prof["transcript_path"] = _prompt("Absolute path to transcript pdf", prof.get("transcript_path", ""))
|
|
63
|
+
prof["degree_status"] = _prompt("Highest degree (bachelors/masters)", prof.get("degree_status", "bachelors"))
|
|
64
|
+
prof["target_term"] = _prompt("Target term", prof.get("target_term", "Fall 2027"))
|
|
65
|
+
prof["target_degree"] = _prompt("Target degree (PhD/MSc)", prof.get("target_degree", "PhD"))
|
|
66
|
+
prof["review_email"] = _prompt("Review inbox (where drafts land)", prof.get("review_email", ""))
|
|
67
|
+
profile_dst.write_text(yaml.safe_dump(prof, sort_keys=False))
|
|
68
|
+
print(f"\nupdated {profile_dst}")
|
|
69
|
+
|
|
70
|
+
print("\nNext steps:")
|
|
71
|
+
print(f" 1. Fill in secrets in {env_dst} (ANTHROPIC_API_KEY, SMTP_*)")
|
|
72
|
+
print(f" 2. Edit {profile_dst} to add your seed_projects (3 to 10 flagships)")
|
|
73
|
+
print(f" 3. Optional: edit {programs_dst} to add target programs")
|
|
74
|
+
print(f" 4. Run: grad-agent sync # scan projects")
|
|
75
|
+
print(f" 5. Run: grad-agent run --dry-run # preview one batch")
|
|
76
|
+
print(f" 6. Run: grad-agent register-claude # get MCP install command")
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cmd_server(_: argparse.Namespace) -> int:
|
|
81
|
+
from . import server as _server
|
|
82
|
+
_server.mcp.run()
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
87
|
+
from . import daily_run
|
|
88
|
+
res = daily_run.run_batch(n=args.n, area=args.area or None, dry_run=args.dry_run)
|
|
89
|
+
print(res)
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def cmd_sync(args: argparse.Namespace) -> int:
|
|
94
|
+
from . import catalog_sync
|
|
95
|
+
src = args.source or "all"
|
|
96
|
+
print(getattr(catalog_sync, f"sync_{src}", catalog_sync.sync_all)() if src != "all"
|
|
97
|
+
else catalog_sync.sync_all())
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _resolve_grad_agent_exe() -> str:
|
|
102
|
+
"""Return the command Claude Code should run to launch this install.
|
|
103
|
+
Order of preference:
|
|
104
|
+
1. `grad-agent` on PATH (clean form, works after `pipx ensurepath`)
|
|
105
|
+
2. sys.argv[0] if it looks like a grad-agent script (matches how the
|
|
106
|
+
user just invoked us)
|
|
107
|
+
3. A `grad-agent` binary next to the current Python (pipx layout)
|
|
108
|
+
4. `<sys.executable> -m grad_agent.cli` (universal fallback)
|
|
109
|
+
"""
|
|
110
|
+
from pathlib import Path
|
|
111
|
+
|
|
112
|
+
on_path = shutil.which("grad-agent")
|
|
113
|
+
if on_path:
|
|
114
|
+
return on_path
|
|
115
|
+
|
|
116
|
+
argv0 = Path(sys.argv[0]) if sys.argv and sys.argv[0] else None
|
|
117
|
+
if argv0 and argv0.is_absolute() and argv0.name.startswith("grad-agent"):
|
|
118
|
+
return str(argv0)
|
|
119
|
+
|
|
120
|
+
adj = Path(sys.executable).parent / "grad-agent"
|
|
121
|
+
if adj.exists():
|
|
122
|
+
return str(adj)
|
|
123
|
+
|
|
124
|
+
return f"{sys.executable} -m grad_agent.cli"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def cmd_register_claude(_: argparse.Namespace) -> int:
|
|
128
|
+
exe = _resolve_grad_agent_exe()
|
|
129
|
+
on_path = shutil.which("grad-agent")
|
|
130
|
+
cmd = f"claude mcp add grad-agent {exe} server"
|
|
131
|
+
print("Run this once to register with Claude Code:\n")
|
|
132
|
+
print(f" {cmd}\n")
|
|
133
|
+
if not on_path:
|
|
134
|
+
print("Note: `grad-agent` is not on your PATH yet.")
|
|
135
|
+
print("Run `pipx ensurepath` and open a new terminal to get the shorter form")
|
|
136
|
+
print("`claude mcp add grad-agent grad-agent server`.\n")
|
|
137
|
+
print("Then in a Claude Code session type /mcp to confirm it's connected.")
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def cmd_path(_: argparse.Namespace) -> int:
|
|
142
|
+
print(config.home())
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def main(argv: list[str] | None = None) -> int:
|
|
147
|
+
parser = argparse.ArgumentParser(prog="grad-agent", description=__doc__)
|
|
148
|
+
sub = parser.add_subparsers(dest="cmd")
|
|
149
|
+
|
|
150
|
+
p_init = sub.add_parser("init", help="Interactive setup")
|
|
151
|
+
p_init.add_argument("--force", action="store_true")
|
|
152
|
+
p_init.add_argument("--interactive", action="store_true", default=True)
|
|
153
|
+
p_init.add_argument("--no-interactive", dest="interactive", action="store_false")
|
|
154
|
+
p_init.set_defaults(func=cmd_init)
|
|
155
|
+
|
|
156
|
+
p_server = sub.add_parser("server", help="Start MCP stdio server")
|
|
157
|
+
p_server.set_defaults(func=cmd_server)
|
|
158
|
+
|
|
159
|
+
p_run = sub.add_parser("run", help="Run today's outreach batch")
|
|
160
|
+
p_run.add_argument("-n", type=int, default=3)
|
|
161
|
+
p_run.add_argument("--area", default="")
|
|
162
|
+
p_run.add_argument("--dry-run", action="store_true")
|
|
163
|
+
p_run.set_defaults(func=cmd_run)
|
|
164
|
+
|
|
165
|
+
p_sync = sub.add_parser("sync", help="Sync project catalog")
|
|
166
|
+
p_sync.add_argument("--source", choices=["all", "github", "hf", "local"], default="all")
|
|
167
|
+
p_sync.set_defaults(func=cmd_sync)
|
|
168
|
+
|
|
169
|
+
p_reg = sub.add_parser("register-claude", help="Print the claude mcp add command")
|
|
170
|
+
p_reg.set_defaults(func=cmd_register_claude)
|
|
171
|
+
|
|
172
|
+
p_path = sub.add_parser("path", help="Print GRAD_AGENT_HOME")
|
|
173
|
+
p_path.set_defaults(func=cmd_path)
|
|
174
|
+
|
|
175
|
+
args = parser.parse_args(argv)
|
|
176
|
+
if not getattr(args, "func", None):
|
|
177
|
+
parser.print_help(); return 1
|
|
178
|
+
|
|
179
|
+
config.load_env_file()
|
|
180
|
+
return args.func(args)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
raise SystemExit(main())
|
grad_agent/config.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Centralised config + paths + profile loader for grad-agent.
|
|
2
|
+
|
|
3
|
+
User data location (in priority order):
|
|
4
|
+
1. $GRAD_AGENT_HOME
|
|
5
|
+
2. ~/.grad-agent/
|
|
6
|
+
3. Current working directory (for legacy/dev)
|
|
7
|
+
|
|
8
|
+
Layout under HOME:
|
|
9
|
+
profile.yaml user identity + preferences
|
|
10
|
+
programs.yaml user's target programs (seeded from template on init)
|
|
11
|
+
.env secrets only (SMTP, ANTHROPIC_API_KEY, GITHUB_TOKEN, ...)
|
|
12
|
+
data/ outreach_log.xlsx, lor_log.xlsx, catalog.json, db.sqlite
|
|
13
|
+
drafts/ per-school drafts
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
import os
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from functools import lru_cache
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
import yaml
|
|
23
|
+
except Exception:
|
|
24
|
+
yaml = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
PACKAGE_ROOT = Path(__file__).parent
|
|
28
|
+
TEMPLATES = PACKAGE_ROOT / "templates"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def home() -> Path:
|
|
32
|
+
env = os.environ.get("GRAD_AGENT_HOME")
|
|
33
|
+
if env:
|
|
34
|
+
return Path(env).expanduser().resolve()
|
|
35
|
+
default = Path.home() / ".grad-agent"
|
|
36
|
+
if default.exists():
|
|
37
|
+
return default
|
|
38
|
+
# Legacy fallback: the folder containing the package
|
|
39
|
+
return PACKAGE_ROOT.parent
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def ensure_home() -> Path:
|
|
43
|
+
h = home()
|
|
44
|
+
h.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
(h / "data").mkdir(exist_ok=True)
|
|
46
|
+
(h / "drafts").mkdir(exist_ok=True)
|
|
47
|
+
return h
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def profile_path() -> Path: return home() / "profile.yaml"
|
|
51
|
+
def programs_path() -> Path: return home() / "programs.yaml"
|
|
52
|
+
def env_path() -> Path: return home() / ".env"
|
|
53
|
+
|
|
54
|
+
def data_dir() -> Path: ensure_home(); return home() / "data"
|
|
55
|
+
def drafts_dir() -> Path: ensure_home(); return home() / "drafts"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Profile:
|
|
60
|
+
name: str = ""
|
|
61
|
+
identity_line: str = ""
|
|
62
|
+
portfolio: str = ""
|
|
63
|
+
cv_path: str = ""
|
|
64
|
+
transcript_path: str = ""
|
|
65
|
+
degree_status: str = "bachelors" # bachelors | masters
|
|
66
|
+
target_term: str = "Fall 2027"
|
|
67
|
+
target_degree: str = "PhD" # PhD | MSc
|
|
68
|
+
research_areas: list[str] = field(default_factory=list)
|
|
69
|
+
projects_dir: str = ""
|
|
70
|
+
seed_projects: list[dict] = field(default_factory=list)
|
|
71
|
+
blog: dict = field(default_factory=dict) # optional plugin config
|
|
72
|
+
review_email: str = "" # where drafts are sent
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def has_masters(self) -> bool:
|
|
76
|
+
return (self.degree_status or "").lower() == "masters"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@lru_cache(maxsize=1)
|
|
80
|
+
def load_profile() -> Profile:
|
|
81
|
+
p = profile_path()
|
|
82
|
+
if not p.exists() or yaml is None:
|
|
83
|
+
return Profile()
|
|
84
|
+
data = yaml.safe_load(p.read_text()) or {}
|
|
85
|
+
return Profile(
|
|
86
|
+
name=data.get("name", ""),
|
|
87
|
+
identity_line=data.get("identity_line", ""),
|
|
88
|
+
portfolio=data.get("portfolio", ""),
|
|
89
|
+
cv_path=str(Path(data.get("cv_path", "")).expanduser()) if data.get("cv_path") else "",
|
|
90
|
+
transcript_path=str(Path(data.get("transcript_path", "")).expanduser()) if data.get("transcript_path") else "",
|
|
91
|
+
degree_status=data.get("degree_status", "bachelors"),
|
|
92
|
+
target_term=data.get("target_term", "Fall 2027"),
|
|
93
|
+
target_degree=data.get("target_degree", "PhD"),
|
|
94
|
+
research_areas=data.get("research_areas", []) or [],
|
|
95
|
+
projects_dir=str(Path(data.get("projects_dir", "")).expanduser()) if data.get("projects_dir") else "",
|
|
96
|
+
seed_projects=data.get("seed_projects", []) or [],
|
|
97
|
+
blog=data.get("blog", {}) or {},
|
|
98
|
+
review_email=data.get("review_email", ""),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_env_file() -> None:
|
|
103
|
+
"""Load .env from GRAD_AGENT_HOME. Silent if missing."""
|
|
104
|
+
try:
|
|
105
|
+
from dotenv import load_dotenv
|
|
106
|
+
except Exception:
|
|
107
|
+
return
|
|
108
|
+
p = env_path()
|
|
109
|
+
if p.exists():
|
|
110
|
+
load_dotenv(p, override=False)
|
|
111
|
+
# legacy: also try package-local .env
|
|
112
|
+
legacy = PACKAGE_ROOT.parent / ".env"
|
|
113
|
+
if legacy.exists():
|
|
114
|
+
load_dotenv(legacy, override=False)
|