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.
Files changed (61) hide show
  1. crewforge/__init__.py +3 -0
  2. crewforge/__main__.py +4 -0
  3. crewforge/cli.py +128 -0
  4. crewforge/cli_support.py +109 -0
  5. crewforge/core/__init__.py +54 -0
  6. crewforge/core/agent.py +118 -0
  7. crewforge/core/client.py +253 -0
  8. crewforge/core/config.py +181 -0
  9. crewforge/core/crew.py +144 -0
  10. crewforge/core/errors.py +106 -0
  11. crewforge/core/parsing.py +110 -0
  12. crewforge/core/providers.py +131 -0
  13. crewforge/core/usage.py +163 -0
  14. crewforge/py.typed +0 -0
  15. crewforge/recipes/__init__.py +1 -0
  16. crewforge/recipes/api/__init__.py +38 -0
  17. crewforge/recipes/api/agents.py +523 -0
  18. crewforge/recipes/api/analytics.py +99 -0
  19. crewforge/recipes/api/appcheck.py +73 -0
  20. crewforge/recipes/api/cli.py +127 -0
  21. crewforge/recipes/api/formatters.py +32 -0
  22. crewforge/recipes/api/generator.py +37 -0
  23. crewforge/recipes/api/pipeline.py +86 -0
  24. crewforge/recipes/api/scaffold.py +68 -0
  25. crewforge/recipes/api/templates/docker-compose.j2 +9 -0
  26. crewforge/recipes/api/templates/dockerfile.j2 +7 -0
  27. crewforge/recipes/api/templates/env.example.j2 +2 -0
  28. crewforge/recipes/api/templates/fastapi_main.j2 +10 -0
  29. crewforge/recipes/api/templates/model.j2 +4 -0
  30. crewforge/recipes/api/templates/requirements.j2 +6 -0
  31. crewforge/recipes/api/templates/route.j2 +4 -0
  32. crewforge/recipes/api/templates/test.j2 +3 -0
  33. crewforge/recipes/api/utils.py +134 -0
  34. crewforge/recipes/content/__init__.py +20 -0
  35. crewforge/recipes/content/agents.py +735 -0
  36. crewforge/recipes/content/cli.py +128 -0
  37. crewforge/recipes/content/export.py +84 -0
  38. crewforge/recipes/content/pipeline.py +222 -0
  39. crewforge/recipes/content/settings.py +63 -0
  40. crewforge/recipes/content/text.py +57 -0
  41. crewforge/recipes/docs/__init__.py +48 -0
  42. crewforge/recipes/docs/agents.py +426 -0
  43. crewforge/recipes/docs/cli.py +156 -0
  44. crewforge/recipes/docs/export.py +118 -0
  45. crewforge/recipes/docs/index.py +117 -0
  46. crewforge/recipes/docs/ingest.py +212 -0
  47. crewforge/recipes/docs/pipeline.py +223 -0
  48. crewforge/recipes/docs/retrieve.py +70 -0
  49. crewforge/recipes/docs/settings.py +54 -0
  50. crewforge/recipes/review/__init__.py +1 -0
  51. crewforge/recipes/review/agents.py +331 -0
  52. crewforge/recipes/review/cli.py +144 -0
  53. crewforge/recipes/review/pipeline.py +125 -0
  54. crewforge/recipes/review/report.py +103 -0
  55. crewforge/recipes/review/sources.py +89 -0
  56. crewforge-0.1.0.dist-info/METADATA +372 -0
  57. crewforge-0.1.0.dist-info/RECORD +61 -0
  58. crewforge-0.1.0.dist-info/WHEEL +5 -0
  59. crewforge-0.1.0.dist-info/entry_points.txt +2 -0
  60. crewforge-0.1.0.dist-info/licenses/LICENSE +21 -0
  61. 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
+ ]