Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions eval/score.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Auto-generated eval script for the Software Factory.

This script was generated by `factory discover`. It runs each eval dimension
as a subprocess and outputs JSON to stdout.

Output format:
{"results": [{"name": str, "score": float, "weight": float, "passed": bool, "details": str}, ...]}

You can edit this file to add custom evals or adjust weights.
Once edited, it becomes a Tier 1 (explicit) eval — the factory will use it as-is.
"""

import ast
import json
import subprocess
import sys
from pathlib import Path


def eval_syntax_check() -> dict:
"""Validate Python files with ast.parse and shell files with bash -n."""
errors = []
total = 0

for py_file in Path("scripts").rglob("*.py"):
total += 1
try:
source = py_file.read_text(errors="replace")
ast.parse(source, filename=str(py_file))
except SyntaxError as e:
errors.append(f"{py_file}:{e.lineno}: {e.msg}")

for sh_file in Path("scripts").rglob("*.sh"):
total += 1
try:
result = subprocess.run(
["bash", "-n", str(sh_file)],
capture_output=True, text=True, timeout=30,
)
if result.returncode != 0:
errors.append(f"{sh_file}: {result.stderr.strip()}")
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
errors.append(f"{sh_file}: {e}")

if total == 0:
return {"name": "syntax_check", "score": 0.0, "weight": 0.60,
"passed": True, "details": "No script files found"}

score = max(0.0, 1.0 - len(errors) / total)
passed = len(errors) == 0
detail = f"{total - len(errors)}/{total} files passed"
if errors:
detail += " | " + "; ".join(errors[:5])

return {"name": "syntax_check", "score": round(score, 3), "weight": 0.60,
"passed": passed, "details": detail[-500:]}

def eval_observability() -> dict:
"""Analyze observability coverage: logging, structured logging, request tracing."""
import ast
import re
from pathlib import Path

skip = {
"tests", "test", ".venv", "venv", "node_modules", "__pycache__",
".git", ".factory", "eval", "dist", "build", ".mypy_cache",
}
log_pats = [
r"\blogger\.\w+\(",
r"\blogging\.\w+\(",
r"\blog\.\w+\(",
r"\bconsole\.\w+\(",
]
struct_pats = [r"\bstructlog\b", r"\bpino\b", r"\bwinston\b",
r"\bslog\.\w+\(", r"\btracing::"]
trace_pats = [r"request.id|req.id|trace.id", r"\bcontextvars\b|ContextVar",
r"\bopentelemetry\b", r"trace.context|TraceContext|span"]

sources = [f for f in Path(".").rglob("*.py")
if not any(p in f.parts for p in skip)]
total_fn = logged_fn = total_log = 0
has_struct = has_trace = False

for src in sources:
try:
code = src.read_text(errors="replace")
except OSError:
continue
try:
tree = ast.parse(code)
except SyntaxError:
continue
lines = code.splitlines()
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
if node.name.startswith("__"):
continue
total_fn += 1
start = node.lineno - 1
end = node.end_lineno or start + 1
body = "\n".join(lines[start:end])
for pat in log_pats:
if re.search(pat, body):
logged_fn += 1
break
for pat in log_pats:
total_log += len(re.findall(pat, code))
for pat in struct_pats:
if re.search(pat, code):
has_struct = True
for pat in trace_pats:
if re.search(pat, code, re.IGNORECASE):
has_trace = True

if total_fn == 0:
return {"name": "observability", "score": 0.0, "weight": 0.15,
"passed": True, "details": "No functions found to analyze"}

cov = logged_fn / total_fn
density = min(1.0, total_log / max(total_fn, 1))
score = 0.40 * cov + 0.25 * float(has_struct) + 0.20 * float(has_trace) + 0.15 * density

details = (f"coverage={cov:.0%} ({logged_fn}/{total_fn}), "
f"structured={'yes' if has_struct else 'no'}, "
f"tracing={'yes' if has_trace else 'no'}, "
f"density={density:.0%}")

return {"name": "observability", "score": round(score, 3), "weight": 0.15,
"passed": score >= 0.3, "details": details}

def eval_capability_surface() -> dict:
"""Count documented slash commands, workflow skills with SKILL.md, and templates."""
count = 0

commands_dir = Path(".claude/commands")
if commands_dir.is_dir():
count += sum(1 for f in commands_dir.iterdir() if f.suffix == ".md")

skills_dir = Path("skills")
if skills_dir.is_dir():
count += sum(1 for d in skills_dir.iterdir()
if d.is_dir() and (d / "SKILL.md").exists())

templates_dir = Path("Templates")
if templates_dir.is_dir():
count += sum(1 for f in templates_dir.iterdir() if f.suffix == ".md")

score = min(1.0, count / 50)
details = f"{count} capabilities found (commands + skills + templates), target=50"

return {"name": "capability_surface", "score": round(score, 3), "weight": 0.15,
"passed": score >= 0.2, "details": details}


def eval_research_grounding() -> dict:
"""Check for archive entries with source citations, research templates, and experiment archives."""
archive_entries = 0

archive_dir = Path(".factory/archive")
if archive_dir.is_dir():
archive_entries = sum(
1 for f in archive_dir.rglob("*")
if f.is_file() and f.suffix in (".md", ".json", ".yaml", ".yml")
)

score = min(1.0, archive_entries / 10)
details = f"{archive_entries} archive entries found, target=10"

return {"name": "research_grounding", "score": round(score, 3), "weight": 0.10,
"passed": score >= 0.1, "details": details}


EVALS = [eval_syntax_check, eval_observability, eval_capability_surface, eval_research_grounding]


def main() -> None:
results = [fn() for fn in EVALS]
output = {"results": results}
json.dump(output, sys.stdout, indent=2)
print() # trailing newline


if __name__ == "__main__":
main()
45 changes: 45 additions & 0 deletions factory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Factory Configuration

## Goal

Improve the work vault template's slash commands, automation scripts, templates, and documentation quality. The vault is an Obsidian-based knowledge management system with Claude Code slash commands, MCP integrations (Slack, Gmail, Google Calendar, Jira, Google Workspace), and Python automation scripts for daily workflows.

## Modifiable

- `scripts/`
- `skills/`
- `Templates/`
- `docs/`
- `eval/`
- `.claude/rules/`
- `.claude/settings.json`
- `CLAUDE.md`
- `SPEC.md`
- `factory.md`

## Guards

- Do NOT modify `.obsidian/` plugin configurations
- Do NOT modify user data files in `01-Components/` through `99-Archive/` (except `Templates/`)
- Do NOT modify `.factory/` internals (managed by the factory system)
- Do NOT send messages to Slack, Gmail, Google Chat, or any external channel

## Command

```
python eval/score.py
```

## Eval Threshold

0.5

## Smoke Test

```
python -c 'import ast; ast.parse(open("scripts/sanitize-for-template.py").read()); print("OK")'
```

## Target Branch

main
14 changes: 14 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[project]
name = "work-vault-template"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"structlog>=24.1.0",
]

[tool.ruff]
target-version = "py310"
line-length = 100

[tool.ruff.lint]
select = ["E", "F", "W", "I"]
28 changes: 28 additions & 0 deletions scripts/email-pull/gemini_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,28 @@
import argparse
import base64
import json
import os
import re
import sys
import uuid
from datetime import datetime
from pathlib import Path

import structlog

structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer()
if os.environ.get("LOG_FORMAT") == "json"
else structlog.dev.ConsoleRenderer(),
],
)
structlog.contextvars.bind_contextvars(run_id=str(uuid.uuid4()))
logger = structlog.get_logger()

from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build

Expand All @@ -33,6 +50,7 @@

def get_credentials():
"""Load OAuth credentials from the MCP token store."""
logger.info("loading_credentials", token_file=str(TOKEN_FILE))
data = json.loads(TOKEN_FILE.read_text())
return Credentials(
token=data["token"],
Expand Down Expand Up @@ -117,6 +135,7 @@ def parse_meeting_subject(subject):

def fetch_doc_content(docs_service, doc_id):
"""Fetch and parse a Google Doc into structured sections."""
logger.info("fetching_doc", doc_id=doc_id)
doc = docs_service.documents().get(documentId=doc_id).execute()

sections = {}
Expand Down Expand Up @@ -176,6 +195,7 @@ def fetch_doc_content(docs_service, doc_id):

def process_message(gmail_service, docs_service, msg_id):
"""Process a single Gmail message ID and return structured data."""
logger.info("processing_message", message_id=msg_id)
result = {
"message_id": msg_id,
"google_doc_url": None,
Expand All @@ -197,6 +217,7 @@ def process_message(gmail_service, docs_service, msg_id):
.execute()
)
except Exception as e:
logger.error("gmail_fetch_failed", message_id=msg_id, error=str(e))
result["error"] = f"gmail_fetch_failed: {e}"
return result

Expand All @@ -210,13 +231,15 @@ def process_message(gmail_service, docs_service, msg_id):
# Extract Google Doc URL from HTML body
html_b64 = find_html_part(msg["payload"])
if not html_b64:
logger.warning("no_html_body", message_id=msg_id)
result["error"] = "no_html_body"
return result

html = base64.urlsafe_b64decode(html_b64).decode("utf-8", errors="replace")
doc_url, doc_id = extract_doc_url(html)

if not doc_url:
logger.warning("no_doc_url_in_email", message_id=msg_id)
result["error"] = "no_doc_url_in_email"
return result

Expand All @@ -233,13 +256,15 @@ def process_message(gmail_service, docs_service, msg_id):
result["next_steps"] = next_steps
result["full_text"] = full_text
except Exception as e:
logger.error("doc_fetch_failed", doc_id=doc_id, error=str(e))
result["error"] = f"doc_fetch_failed: {e}"

return result


def process_doc_id(docs_service, doc_id):
"""Process a document ID directly (no Gmail fetch needed)."""
logger.info("processing_doc_id", doc_id=doc_id)
result = {
"message_id": None,
"google_doc_url": f"https://docs.google.com/document/d/{doc_id}",
Expand Down Expand Up @@ -270,12 +295,14 @@ def process_doc_id(docs_service, doc_id):
result["meeting_name"] = name
result["meeting_date"] = date
except Exception as e:
logger.error("doc_fetch_failed", doc_id=doc_id, error=str(e))
result["error"] = f"doc_fetch_failed: {e}"

return result


def main():
logger.info("gemini_docs_started")
parser = argparse.ArgumentParser(
description="Fetch Gemini meeting note content from Gmail/Google Docs."
)
Expand Down Expand Up @@ -306,6 +333,7 @@ def main():
for msg_id in args.message_ids:
results.append(process_message(gmail_service, docs_service, msg_id))

logger.info("gemini_docs_completed", result_count=len(results))
json.dump(results, sys.stdout, indent=2)
print()

Expand Down
Loading