crewforge 0.1.0__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.
- crewforge/__init__.py +3 -0
- crewforge/__main__.py +4 -0
- crewforge/cli.py +128 -0
- crewforge/cli_support.py +109 -0
- crewforge/core/__init__.py +54 -0
- crewforge/core/agent.py +118 -0
- crewforge/core/client.py +253 -0
- crewforge/core/config.py +181 -0
- crewforge/core/crew.py +144 -0
- crewforge/core/errors.py +106 -0
- crewforge/core/parsing.py +110 -0
- crewforge/core/providers.py +131 -0
- crewforge/core/usage.py +163 -0
- crewforge/py.typed +0 -0
- crewforge/recipes/__init__.py +1 -0
- crewforge/recipes/api/__init__.py +38 -0
- crewforge/recipes/api/agents.py +523 -0
- crewforge/recipes/api/analytics.py +99 -0
- crewforge/recipes/api/appcheck.py +73 -0
- crewforge/recipes/api/cli.py +127 -0
- crewforge/recipes/api/formatters.py +32 -0
- crewforge/recipes/api/generator.py +37 -0
- crewforge/recipes/api/pipeline.py +86 -0
- crewforge/recipes/api/scaffold.py +68 -0
- crewforge/recipes/api/templates/docker-compose.j2 +9 -0
- crewforge/recipes/api/templates/dockerfile.j2 +7 -0
- crewforge/recipes/api/templates/env.example.j2 +2 -0
- crewforge/recipes/api/templates/fastapi_main.j2 +10 -0
- crewforge/recipes/api/templates/model.j2 +4 -0
- crewforge/recipes/api/templates/requirements.j2 +6 -0
- crewforge/recipes/api/templates/route.j2 +4 -0
- crewforge/recipes/api/templates/test.j2 +3 -0
- crewforge/recipes/api/utils.py +134 -0
- crewforge/recipes/content/__init__.py +20 -0
- crewforge/recipes/content/agents.py +735 -0
- crewforge/recipes/content/cli.py +128 -0
- crewforge/recipes/content/export.py +84 -0
- crewforge/recipes/content/pipeline.py +222 -0
- crewforge/recipes/content/settings.py +63 -0
- crewforge/recipes/content/text.py +57 -0
- crewforge/recipes/docs/__init__.py +48 -0
- crewforge/recipes/docs/agents.py +426 -0
- crewforge/recipes/docs/cli.py +156 -0
- crewforge/recipes/docs/export.py +118 -0
- crewforge/recipes/docs/index.py +117 -0
- crewforge/recipes/docs/ingest.py +212 -0
- crewforge/recipes/docs/pipeline.py +223 -0
- crewforge/recipes/docs/retrieve.py +70 -0
- crewforge/recipes/docs/settings.py +54 -0
- crewforge/recipes/review/__init__.py +1 -0
- crewforge/recipes/review/agents.py +331 -0
- crewforge/recipes/review/cli.py +144 -0
- crewforge/recipes/review/pipeline.py +125 -0
- crewforge/recipes/review/report.py +103 -0
- crewforge/recipes/review/sources.py +89 -0
- crewforge-0.1.0.dist-info/METADATA +372 -0
- crewforge-0.1.0.dist-info/RECORD +61 -0
- crewforge-0.1.0.dist-info/WHEEL +5 -0
- crewforge-0.1.0.dist-info/entry_points.txt +2 -0
- crewforge-0.1.0.dist-info/licenses/LICENSE +21 -0
- crewforge-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""``crewforge write`` — multi-agent article pipeline (ported from contentforge)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from pydantic import ValidationError
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
|
|
9
|
+
from ...cli_support import (
|
|
10
|
+
EXIT_USAGE,
|
|
11
|
+
console,
|
|
12
|
+
err_console,
|
|
13
|
+
fail,
|
|
14
|
+
llm_config,
|
|
15
|
+
print_usage,
|
|
16
|
+
recipe_settings,
|
|
17
|
+
run,
|
|
18
|
+
)
|
|
19
|
+
from .pipeline import ContentPipeline
|
|
20
|
+
from .settings import PUBLISH_TARGETS, ContentSettings
|
|
21
|
+
from .text import word_count
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@click.command("write")
|
|
25
|
+
@click.argument("topic")
|
|
26
|
+
@click.option("--words", "-w", type=int, default=None, help="Target word count [default: 2000].")
|
|
27
|
+
@click.option("--language", "-l", default=None, help="Article language [default: en].")
|
|
28
|
+
@click.option("--output", "-o", default=None, help="Output directory [default: ./output].")
|
|
29
|
+
@click.option(
|
|
30
|
+
"--format",
|
|
31
|
+
"-f",
|
|
32
|
+
"formats",
|
|
33
|
+
multiple=True,
|
|
34
|
+
type=click.Choice(PUBLISH_TARGETS),
|
|
35
|
+
help="Publish format (repeatable) [default: markdown].",
|
|
36
|
+
)
|
|
37
|
+
@click.option("--seo/--no-seo", default=None, help="Run the SEO agent [default: on].")
|
|
38
|
+
@click.option("--translate", "-t", multiple=True, help="Also translate to LANG (repeatable).")
|
|
39
|
+
@click.option("--threshold", type=float, default=None, help="Quality threshold 0-1 [default: 0.8].")
|
|
40
|
+
@click.option("--max-iter", type=int, default=None, help="Max edit/quality rounds [default: 3].")
|
|
41
|
+
@click.option(
|
|
42
|
+
"--accept-below-threshold",
|
|
43
|
+
is_flag=True,
|
|
44
|
+
help="Publish even if the final quality score stays below the threshold (warns).",
|
|
45
|
+
)
|
|
46
|
+
@click.pass_context
|
|
47
|
+
def write(
|
|
48
|
+
ctx,
|
|
49
|
+
topic,
|
|
50
|
+
words,
|
|
51
|
+
language,
|
|
52
|
+
output,
|
|
53
|
+
formats,
|
|
54
|
+
seo,
|
|
55
|
+
translate,
|
|
56
|
+
threshold,
|
|
57
|
+
max_iter,
|
|
58
|
+
accept_below_threshold,
|
|
59
|
+
):
|
|
60
|
+
"""Research, outline, write, SEO-check, edit, score, translate and publish an article.
|
|
61
|
+
|
|
62
|
+
The research agent has no web access: facts come from the model's own
|
|
63
|
+
knowledge, so review the article before publishing it.
|
|
64
|
+
|
|
65
|
+
\b
|
|
66
|
+
Example:
|
|
67
|
+
crewforge write "Why unit tests matter" --words 600 -f markdown -f html
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
settings = ContentSettings.from_mapping(recipe_settings(ctx, "content"))
|
|
71
|
+
pipe = settings.pipeline.model_dump()
|
|
72
|
+
overrides = {
|
|
73
|
+
"target_word_count": words,
|
|
74
|
+
"language": language,
|
|
75
|
+
"seo_enabled": seo,
|
|
76
|
+
"quality_threshold": threshold,
|
|
77
|
+
"max_iterations": max_iter,
|
|
78
|
+
}
|
|
79
|
+
pipe.update({k: v for k, v in overrides.items() if v is not None})
|
|
80
|
+
if formats:
|
|
81
|
+
pipe["publish_targets"] = list(formats)
|
|
82
|
+
if translate:
|
|
83
|
+
pipe["enable_translation"] = True
|
|
84
|
+
pipe["target_languages"] = list(translate)
|
|
85
|
+
if accept_below_threshold:
|
|
86
|
+
pipe["accept_below_threshold"] = True
|
|
87
|
+
settings = ContentSettings(
|
|
88
|
+
pipeline=pipe,
|
|
89
|
+
agents=settings.agents,
|
|
90
|
+
output_dir=output or settings.output_dir,
|
|
91
|
+
)
|
|
92
|
+
except ValidationError as exc:
|
|
93
|
+
first = exc.errors()[0]
|
|
94
|
+
loc = ".".join(str(p) for p in first.get("loc", ()))
|
|
95
|
+
fail(f"configuration: {loc}: {first.get('msg')}", EXIT_USAGE)
|
|
96
|
+
|
|
97
|
+
cfg = settings.pipeline
|
|
98
|
+
llm = llm_config(ctx)
|
|
99
|
+
langs = ", ".join(cfg.target_languages) if cfg.enable_translation else "off"
|
|
100
|
+
err_console.print(
|
|
101
|
+
f"[bold cyan]crewforge write[/] · {llm.provider}/{llm.model} · {cfg.target_word_count} "
|
|
102
|
+
f"words · {cfg.language} · SEO {'on' if cfg.seo_enabled else 'off'} · threshold "
|
|
103
|
+
f"{cfg.quality_threshold} · translate {langs}",
|
|
104
|
+
highlight=False,
|
|
105
|
+
soft_wrap=True,
|
|
106
|
+
)
|
|
107
|
+
result = run(ContentPipeline(llm, settings).run(topic))
|
|
108
|
+
|
|
109
|
+
if result.status == "below_threshold":
|
|
110
|
+
err_console.print(
|
|
111
|
+
f"[yellow]warning: quality score {result.quality_score:.0f} is below the threshold "
|
|
112
|
+
f"{cfg.quality_threshold * 100:.0f} (accepted with --accept-below-threshold)[/]"
|
|
113
|
+
)
|
|
114
|
+
files = "\n".join(f" {f}" for f in result.output_files)
|
|
115
|
+
console.print(
|
|
116
|
+
Panel(
|
|
117
|
+
f"Article: {word_count(result.article)} words · quality score "
|
|
118
|
+
f"{result.quality_score:.0f}/100\nFiles:\n{files}\nMetrics: {result.metrics_path}",
|
|
119
|
+
title=f"crewforge write — {topic}",
|
|
120
|
+
border_style="green" if result.ok else "yellow",
|
|
121
|
+
),
|
|
122
|
+
soft_wrap=True,
|
|
123
|
+
)
|
|
124
|
+
if result.tracker is not None:
|
|
125
|
+
print_usage(result.tracker)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
commands = [write]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Write articles to disk as Markdown (with front matter), HTML or JSON."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html as html_lib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def export_markdown(content: str, metadata: dict, output_path: str | Path) -> Path:
|
|
14
|
+
"""Markdown with a YAML front-matter block."""
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
output = Path(output_path)
|
|
18
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
front = yaml.safe_dump(metadata, sort_keys=False, allow_unicode=True).strip()
|
|
20
|
+
output.write_text(f"---\n{front}\n---\n\n{content}", encoding="utf-8")
|
|
21
|
+
return output
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _markdown_to_html(content: str) -> str:
|
|
25
|
+
"""Deliberately small Markdown subset: headings, bold, italics, paragraphs."""
|
|
26
|
+
lines = []
|
|
27
|
+
for raw in content.splitlines():
|
|
28
|
+
line = html_lib.escape(raw)
|
|
29
|
+
if not line.strip():
|
|
30
|
+
continue
|
|
31
|
+
m = re.match(r"^(#{1,3}) (.+)$", line)
|
|
32
|
+
if m:
|
|
33
|
+
level = len(m.group(1))
|
|
34
|
+
lines.append(f"<h{level}>{m.group(2)}</h{level}>")
|
|
35
|
+
continue
|
|
36
|
+
line = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", line)
|
|
37
|
+
line = re.sub(r"\*(.+?)\*", r"<em>\1</em>", line)
|
|
38
|
+
lines.append(f"<p>{line}</p>")
|
|
39
|
+
return "\n ".join(lines)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def export_html(content: str, metadata: dict, output_path: str | Path) -> Path:
|
|
43
|
+
"""Standalone semantic HTML5 page."""
|
|
44
|
+
output = Path(output_path)
|
|
45
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
esc = html_lib.escape
|
|
47
|
+
title = esc(str(metadata.get("title", "Untitled")))
|
|
48
|
+
description = esc(str(metadata.get("description", "")))
|
|
49
|
+
lang = esc(str(metadata.get("language", "en")))
|
|
50
|
+
date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
51
|
+
page = f"""<!DOCTYPE html>
|
|
52
|
+
<html lang="{lang}">
|
|
53
|
+
<head>
|
|
54
|
+
<meta charset="UTF-8">
|
|
55
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
56
|
+
<meta name="description" content="{description}">
|
|
57
|
+
<meta property="og:title" content="{title}">
|
|
58
|
+
<meta property="og:description" content="{description}">
|
|
59
|
+
<title>{title}</title>
|
|
60
|
+
<style>
|
|
61
|
+
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
62
|
+
max-width: 800px; margin: 0 auto; padding: 2rem; line-height: 1.6; }}
|
|
63
|
+
</style>
|
|
64
|
+
</head>
|
|
65
|
+
<body>
|
|
66
|
+
<article>
|
|
67
|
+
{_markdown_to_html(content)}
|
|
68
|
+
</article>
|
|
69
|
+
<footer>
|
|
70
|
+
<p><small>Generated {date} with crewforge (content recipe).
|
|
71
|
+
Review before publishing.</small></p>
|
|
72
|
+
</footer>
|
|
73
|
+
</body>
|
|
74
|
+
</html>
|
|
75
|
+
"""
|
|
76
|
+
output.write_text(page, encoding="utf-8")
|
|
77
|
+
return output
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def export_json_report(data: dict[str, Any], output_path: str | Path) -> Path:
|
|
81
|
+
output = Path(output_path)
|
|
82
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
output.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
84
|
+
return output
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Content pipeline: research → outline → write → SEO → (edit ↔ quality) → translate → publish.
|
|
2
|
+
|
|
3
|
+
Every step runs through ``Crew.step``, so an agent that errors or returns
|
|
4
|
+
nothing stops the run naming itself — contentforge never checked the writer,
|
|
5
|
+
SEO, editor or translator results. A quality score that never reaches the
|
|
6
|
+
threshold is also a failure unless ``accept_below_threshold`` is set.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from ...core.client import LLMClient
|
|
19
|
+
from ...core.config import LLMConfig
|
|
20
|
+
from ...core.crew import Crew
|
|
21
|
+
from ...core.errors import AgentFailure, CrewFailedError
|
|
22
|
+
from ...core.usage import UsageTracker
|
|
23
|
+
from .agents import AGENT_REGISTRY, AgentResult, ContentAgent
|
|
24
|
+
from .settings import ContentSettings
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class PipelineResult:
|
|
31
|
+
"""Final result from the complete pipeline."""
|
|
32
|
+
|
|
33
|
+
status: str = "success" # success | below_threshold
|
|
34
|
+
article: str = ""
|
|
35
|
+
research: str = ""
|
|
36
|
+
outline: str = ""
|
|
37
|
+
seo_analysis: str = ""
|
|
38
|
+
quality_report: str = ""
|
|
39
|
+
quality_score: float | None = None
|
|
40
|
+
publisher_output: str = ""
|
|
41
|
+
output_files: list[str] = field(default_factory=list)
|
|
42
|
+
translations: dict[str, str] = field(default_factory=dict)
|
|
43
|
+
agent_results: dict[str, AgentResult] = field(default_factory=dict)
|
|
44
|
+
metrics_path: str = ""
|
|
45
|
+
total_tokens: int = 0
|
|
46
|
+
pipeline_duration_s: float = 0.0
|
|
47
|
+
tracker: UsageTracker | None = field(default=None, repr=False)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def ok(self) -> bool:
|
|
51
|
+
return self.status == "success"
|
|
52
|
+
|
|
53
|
+
def summary(self) -> dict[str, Any]:
|
|
54
|
+
return {
|
|
55
|
+
"status": self.status,
|
|
56
|
+
"total_tokens": self.total_tokens,
|
|
57
|
+
"pipeline_duration_s": round(self.pipeline_duration_s, 2),
|
|
58
|
+
"agents_run": list(self.agent_results.keys()),
|
|
59
|
+
"article_length": len(self.article),
|
|
60
|
+
"word_count": len(self.article.split()),
|
|
61
|
+
"quality_score": self.quality_score,
|
|
62
|
+
"translations": list(self.translations.keys()),
|
|
63
|
+
"output_files": self.output_files,
|
|
64
|
+
"metrics_path": self.metrics_path,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ContentPipeline:
|
|
69
|
+
"""Coordinates the eight content agents on one crew."""
|
|
70
|
+
|
|
71
|
+
AGENT_ORDER = list(AGENT_REGISTRY)
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
llm: LLMConfig,
|
|
76
|
+
settings: ContentSettings | None = None,
|
|
77
|
+
*,
|
|
78
|
+
client: LLMClient | None = None,
|
|
79
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
self.llm = llm
|
|
82
|
+
self.settings = settings or ContentSettings()
|
|
83
|
+
self._client = client
|
|
84
|
+
self._transport = transport
|
|
85
|
+
|
|
86
|
+
def _agent(self, crew: Crew, name: str) -> ContentAgent:
|
|
87
|
+
return AGENT_REGISTRY[name](crew.client, crew.tracker, config=self.settings)
|
|
88
|
+
|
|
89
|
+
async def _step(self, crew: Crew, result: PipelineResult, key: str, agent, **kwargs):
|
|
90
|
+
out: AgentResult = await crew.step(key, agent.execute(**kwargs))
|
|
91
|
+
if not out.content.strip(): # defence in depth; ask() already refuses blanks
|
|
92
|
+
failure = AgentFailure(key, "returned empty output", "empty")
|
|
93
|
+
raise CrewFailedError(crew.name, [failure])
|
|
94
|
+
result.agent_results[key] = out
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
async def run(self, topic: str) -> PipelineResult:
|
|
98
|
+
cfg = self.settings.pipeline
|
|
99
|
+
result = PipelineResult()
|
|
100
|
+
crew = Crew("content", self.llm, client=self._client, transport=self._transport)
|
|
101
|
+
try:
|
|
102
|
+
async with crew:
|
|
103
|
+
research = await self._step(
|
|
104
|
+
crew, result, "research", self._agent(crew, "research"), topic=topic
|
|
105
|
+
)
|
|
106
|
+
result.research = research.content
|
|
107
|
+
|
|
108
|
+
outline = await self._step(
|
|
109
|
+
crew,
|
|
110
|
+
result,
|
|
111
|
+
"outline",
|
|
112
|
+
self._agent(crew, "outline"),
|
|
113
|
+
research_data=research.content,
|
|
114
|
+
target_words=cfg.target_word_count,
|
|
115
|
+
)
|
|
116
|
+
result.outline = outline.content
|
|
117
|
+
|
|
118
|
+
draft = await self._step(
|
|
119
|
+
crew,
|
|
120
|
+
result,
|
|
121
|
+
"writer",
|
|
122
|
+
self._agent(crew, "writer"),
|
|
123
|
+
outline=outline.content,
|
|
124
|
+
research_data=research.content,
|
|
125
|
+
language=cfg.language,
|
|
126
|
+
)
|
|
127
|
+
current = draft.content
|
|
128
|
+
|
|
129
|
+
seo_notes = ""
|
|
130
|
+
if cfg.seo_enabled:
|
|
131
|
+
seo = await self._step(
|
|
132
|
+
crew, result, "seo", self._agent(crew, "seo"), content=current
|
|
133
|
+
)
|
|
134
|
+
result.seo_analysis = seo_notes = seo.content
|
|
135
|
+
|
|
136
|
+
threshold = cfg.quality_threshold * 100
|
|
137
|
+
for iteration in range(cfg.max_iterations):
|
|
138
|
+
edited = await self._step(
|
|
139
|
+
crew,
|
|
140
|
+
result,
|
|
141
|
+
"editor",
|
|
142
|
+
self._agent(crew, "editor"),
|
|
143
|
+
content=current,
|
|
144
|
+
seo_recommendations=seo_notes,
|
|
145
|
+
)
|
|
146
|
+
current = edited.content
|
|
147
|
+
quality = await self._step(
|
|
148
|
+
crew,
|
|
149
|
+
result,
|
|
150
|
+
"quality",
|
|
151
|
+
self._agent(crew, "quality"),
|
|
152
|
+
content=current,
|
|
153
|
+
threshold=cfg.quality_threshold,
|
|
154
|
+
title=topic,
|
|
155
|
+
)
|
|
156
|
+
result.quality_report = quality.content
|
|
157
|
+
result.quality_score = quality.metadata["overall_score"]
|
|
158
|
+
if result.quality_score >= threshold:
|
|
159
|
+
break
|
|
160
|
+
logger.info(
|
|
161
|
+
"[content] quality %.0f < %.0f (iteration %d/%d)",
|
|
162
|
+
result.quality_score,
|
|
163
|
+
threshold,
|
|
164
|
+
iteration + 1,
|
|
165
|
+
cfg.max_iterations,
|
|
166
|
+
)
|
|
167
|
+
result.article = current
|
|
168
|
+
|
|
169
|
+
if result.quality_score is not None and result.quality_score < threshold:
|
|
170
|
+
message = (
|
|
171
|
+
f"final quality score {result.quality_score:.0f} is below the "
|
|
172
|
+
f"threshold {threshold:.0f} after {cfg.max_iterations} edit round(s)"
|
|
173
|
+
)
|
|
174
|
+
if not cfg.accept_below_threshold:
|
|
175
|
+
failure = AgentFailure("quality", message, "below_threshold")
|
|
176
|
+
crew.failures.append(failure)
|
|
177
|
+
raise CrewFailedError(crew.name, [failure])
|
|
178
|
+
result.status = "below_threshold"
|
|
179
|
+
|
|
180
|
+
if cfg.enable_translation and cfg.target_languages:
|
|
181
|
+
work = {
|
|
182
|
+
f"translator_{lang}": self._agent(crew, "translator").execute(
|
|
183
|
+
content=current, target_language=lang, source_language=cfg.language
|
|
184
|
+
)
|
|
185
|
+
for lang in cfg.target_languages
|
|
186
|
+
}
|
|
187
|
+
for key, out in (await crew.parallel(work)).items():
|
|
188
|
+
if not out.content.strip():
|
|
189
|
+
raise CrewFailedError(
|
|
190
|
+
crew.name, [AgentFailure(key, "returned empty output", "empty")]
|
|
191
|
+
)
|
|
192
|
+
result.agent_results[key] = out
|
|
193
|
+
result.translations[key.removeprefix("translator_")] = out.content
|
|
194
|
+
|
|
195
|
+
publisher = await self._step(
|
|
196
|
+
crew,
|
|
197
|
+
result,
|
|
198
|
+
"publisher",
|
|
199
|
+
self._agent(crew, "publisher"),
|
|
200
|
+
content=current,
|
|
201
|
+
title=topic,
|
|
202
|
+
publish_targets=cfg.publish_targets,
|
|
203
|
+
output_dir=self.settings.output_dir,
|
|
204
|
+
language=cfg.language,
|
|
205
|
+
)
|
|
206
|
+
result.publisher_output = publisher.content
|
|
207
|
+
result.output_files = list(publisher.metadata.get("output_files", []))
|
|
208
|
+
out_dir = Path(self.settings.output_dir)
|
|
209
|
+
for lang, text in result.translations.items():
|
|
210
|
+
path = out_dir / f"translation.{lang}.md"
|
|
211
|
+
path.write_text(text, encoding="utf-8")
|
|
212
|
+
result.output_files.append(str(path))
|
|
213
|
+
finally:
|
|
214
|
+
result.tracker = crew.tracker
|
|
215
|
+
result.total_tokens = crew.tracker.total_tokens
|
|
216
|
+
result.pipeline_duration_s = crew.tracker.duration_s
|
|
217
|
+
metrics = Path(self.settings.output_dir) / "metrics" / f"{crew.tracker.run_id}.json"
|
|
218
|
+
result.metrics_path = str(crew.tracker.save(metrics))
|
|
219
|
+
return result
|
|
220
|
+
|
|
221
|
+
def get_agent_list(self) -> list[dict[str, str]]:
|
|
222
|
+
return [{"name": n, "description": c.description} for n, c in AGENT_REGISTRY.items()]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Settings for the content recipe (the ``content:`` section of crewforge.yaml)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field, field_validator
|
|
8
|
+
|
|
9
|
+
PUBLISH_TARGETS = ("markdown", "html", "json")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PipelineConfig(BaseModel):
|
|
13
|
+
topic: str = ""
|
|
14
|
+
target_word_count: int = 2000
|
|
15
|
+
language: str = "en"
|
|
16
|
+
seo_enabled: bool = True
|
|
17
|
+
quality_threshold: float = 0.8 # fraction of 100
|
|
18
|
+
max_iterations: int = 3 # editor <-> quality rounds
|
|
19
|
+
accept_below_threshold: bool = False
|
|
20
|
+
enable_translation: bool = False
|
|
21
|
+
target_languages: list[str] = Field(default_factory=lambda: ["zh", "ms"])
|
|
22
|
+
publish_targets: list[str] = Field(default_factory=lambda: ["markdown"])
|
|
23
|
+
|
|
24
|
+
@field_validator("quality_threshold")
|
|
25
|
+
@classmethod
|
|
26
|
+
def _threshold_range(cls, v: float) -> float:
|
|
27
|
+
if not 0 <= v <= 1:
|
|
28
|
+
raise ValueError("quality_threshold is a fraction between 0 and 1")
|
|
29
|
+
return v
|
|
30
|
+
|
|
31
|
+
@field_validator("max_iterations")
|
|
32
|
+
@classmethod
|
|
33
|
+
def _iterations_positive(cls, v: int) -> int:
|
|
34
|
+
if v < 1:
|
|
35
|
+
raise ValueError("max_iterations must be >= 1")
|
|
36
|
+
return v
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AgentConfig(BaseModel):
|
|
40
|
+
"""Per-agent overrides (model, sampling, system prompt)."""
|
|
41
|
+
|
|
42
|
+
name: str
|
|
43
|
+
enabled: bool = True
|
|
44
|
+
model_override: str | None = None
|
|
45
|
+
temperature_override: float | None = None
|
|
46
|
+
max_tokens_override: int | None = None
|
|
47
|
+
system_prompt_override: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ContentSettings(BaseModel):
|
|
51
|
+
pipeline: PipelineConfig = Field(default_factory=PipelineConfig)
|
|
52
|
+
agents: list[AgentConfig] = Field(default_factory=list)
|
|
53
|
+
output_dir: str = "./output"
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_mapping(cls, data: dict[str, Any] | None) -> ContentSettings:
|
|
57
|
+
return cls(**(data or {}))
|
|
58
|
+
|
|
59
|
+
def get_agent_config(self, name: str) -> AgentConfig:
|
|
60
|
+
for agent in self.agents:
|
|
61
|
+
if agent.name == name:
|
|
62
|
+
return agent
|
|
63
|
+
return AgentConfig(name=name)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Text helpers for the content recipe.
|
|
2
|
+
|
|
3
|
+
JSON extraction lives in the core (:func:`crewforge.core.extract_json`); it is
|
|
4
|
+
re-exported here because contentforge exposed it from its text utilities.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from ...core.parsing import extract_json
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"extract_frontmatter",
|
|
15
|
+
"extract_json",
|
|
16
|
+
"reading_time",
|
|
17
|
+
"slugify",
|
|
18
|
+
"truncate",
|
|
19
|
+
"word_count",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def word_count(text: str) -> int:
|
|
24
|
+
"""Count words; each CJK character counts as one word."""
|
|
25
|
+
cjk = len(re.findall(r"[一-鿿㐀-䶿]", text))
|
|
26
|
+
english_words = len(re.findall(r"[a-zA-Z]+", text))
|
|
27
|
+
return cjk + english_words
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def extract_frontmatter(text: str) -> tuple[dict, str]:
|
|
31
|
+
"""Split YAML front matter from a Markdown document."""
|
|
32
|
+
if text.startswith("---"):
|
|
33
|
+
parts = text.split("---", 2)
|
|
34
|
+
if len(parts) >= 3:
|
|
35
|
+
import yaml
|
|
36
|
+
|
|
37
|
+
fm = yaml.safe_load(parts[1]) or {}
|
|
38
|
+
return fm, parts[2].strip()
|
|
39
|
+
return {}, text
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def truncate(text: str, max_chars: int = 100, suffix: str = "...") -> str:
|
|
43
|
+
if len(text) <= max_chars:
|
|
44
|
+
return text
|
|
45
|
+
return text[: max_chars - len(suffix)] + suffix
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def slugify(text: str) -> str:
|
|
49
|
+
text = text.lower().strip()
|
|
50
|
+
text = re.sub(r"[^\w\s-]", "", text)
|
|
51
|
+
text = re.sub(r"[-\s]+", "-", text)
|
|
52
|
+
return text.strip("-")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def reading_time(text: str, wpm: int = 200) -> str:
|
|
56
|
+
minutes = max(1, round(word_count(text) / wpm))
|
|
57
|
+
return f"{minutes} min read"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""``docs`` recipe (from documind): ingest documents, then answer questions about
|
|
2
|
+
them with ``[Source N]`` citations, optional fact-checking, and summaries.
|
|
3
|
+
|
|
4
|
+
Ingestion, indexing and keyword retrieval are deterministic (no LLM calls);
|
|
5
|
+
the answerer, fact-checker, summarizer, reranker and reference formatter call
|
|
6
|
+
the LLM through :class:`crewforge.core.Agent`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .agents import (
|
|
10
|
+
Answer,
|
|
11
|
+
AnswerAgent,
|
|
12
|
+
CitationAgent,
|
|
13
|
+
FactCheckAgent,
|
|
14
|
+
FactCheckResult,
|
|
15
|
+
FormattedCitation,
|
|
16
|
+
RerankAgent,
|
|
17
|
+
SummarizerAgent,
|
|
18
|
+
Summary,
|
|
19
|
+
)
|
|
20
|
+
from .export import ExportAgent
|
|
21
|
+
from .index import IndexAgent, IndexEntry
|
|
22
|
+
from .ingest import Document, IngestAgent, TextChunk
|
|
23
|
+
from .pipeline import DocsPipeline, PipelineResult
|
|
24
|
+
from .retrieve import RetrievalResult, RetrieverAgent
|
|
25
|
+
from .settings import DocsSettings
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"Answer",
|
|
29
|
+
"AnswerAgent",
|
|
30
|
+
"CitationAgent",
|
|
31
|
+
"DocsPipeline",
|
|
32
|
+
"DocsSettings",
|
|
33
|
+
"Document",
|
|
34
|
+
"ExportAgent",
|
|
35
|
+
"FactCheckAgent",
|
|
36
|
+
"FactCheckResult",
|
|
37
|
+
"FormattedCitation",
|
|
38
|
+
"IndexAgent",
|
|
39
|
+
"IndexEntry",
|
|
40
|
+
"IngestAgent",
|
|
41
|
+
"PipelineResult",
|
|
42
|
+
"RerankAgent",
|
|
43
|
+
"RetrievalResult",
|
|
44
|
+
"RetrieverAgent",
|
|
45
|
+
"SummarizerAgent",
|
|
46
|
+
"Summary",
|
|
47
|
+
"TextChunk",
|
|
48
|
+
]
|