postcraftin 2.0.1__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Emre Duelger
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: postcraftin
3
+ Version: 2.0.1
4
+ Requires-Python: >=3.10
5
+ License-File: LICENSE
6
+ Requires-Dist: requests>=2.31
7
+ Requires-Dist: click>=8.1
8
+ Requires-Dist: rich>=13.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest; extra == "dev"
11
+ Requires-Dist: ruff; extra == "dev"
12
+ Requires-Dist: python-semantic-release>=10.0; extra == "dev"
13
+ Dynamic: license-file
@@ -0,0 +1,2 @@
1
+ # postcraft
2
+ 🤖 AI-powered LinkedIn post generator using local LLMs via Ollama. Personalized ghostwriter with onboarding profile, Few-Shot prompting, and CLI interface.
File without changes
@@ -0,0 +1,65 @@
1
+ import click
2
+ from rich.console import Console
3
+ from rich.table import Table
4
+
5
+ from postcraftin.config import DEFAULT_MODEL
6
+ from postcraftin.generator import generate_post
7
+ from postcraftin.onboarding import run_onboarding
8
+ from postcraftin.profile import list_profiles, load_profile, get_default_profile_name
9
+
10
+ console = Console()
11
+
12
+
13
+ @click.group()
14
+ def cli():
15
+ """Postcraftin — AI-powered LinkedIn post generator."""
16
+ pass
17
+
18
+
19
+ @cli.command()
20
+ @click.option("--name", default="default", show_default=True, help="Profile name to create.")
21
+ def onboard(name: str):
22
+ """Run the interactive onboarding wizard to create a profile."""
23
+ run_onboarding(name)
24
+
25
+
26
+ @cli.command()
27
+ @click.option("-t", "--topic", required=True, help="Topic for the LinkedIn post.")
28
+ @click.option("--length", "-l", "post_length", default="mittel",
29
+ type=click.Choice(["kurz", "mittel", "lang"], case_sensitive=False),
30
+ help="Post length: kurz (150-500 Zeichen), mittel (1.200-1.500 Zeichen), lang (1.900-2.500 Zeichen).")
31
+ @click.option("--profile", default=None, help="Profile name to use (default: first available).")
32
+ @click.option("--model", default=DEFAULT_MODEL, show_default=True, help="Ollama model to use.")
33
+ def generate(topic: str, post_length: str, profile: str | None, model: str):
34
+ """Generate a LinkedIn post for a given topic."""
35
+ profile_name = profile or get_default_profile_name()
36
+ if not profile_name:
37
+ console.print("[red]No profile found. Run 'postcraftin onboard' first.[/red]")
38
+ raise SystemExit(1)
39
+ try:
40
+ profile_data = load_profile(profile_name)
41
+ except FileNotFoundError as e:
42
+ console.print(f"[red]{e}[/red]")
43
+ raise SystemExit(1)
44
+
45
+ console.print(f"[cyan]Generating post about:[/cyan] {topic}")
46
+ console.print(f"[dim]Profile: {profile_name} | Model: {model} | Length: {post_length}[/dim]\n")
47
+ generate_post(topic, profile_data, post_length=post_length, model=model)
48
+
49
+
50
+ @cli.command(name="list-profiles")
51
+ def list_profiles_cmd():
52
+ """List all available profiles."""
53
+ profiles = list_profiles()
54
+ if not profiles:
55
+ console.print("[yellow]No profiles found. Run 'postcraftin onboard' to create one.[/yellow]")
56
+ return
57
+ table = Table(title="postcraftin Profiles")
58
+ table.add_column("Name", style="cyan")
59
+ for p in profiles:
60
+ table.add_row(p)
61
+ console.print(table)
62
+
63
+
64
+ if __name__ == "__main__":
65
+ cli()
@@ -0,0 +1,2 @@
1
+ OLLAMA_HOST = "http://localhost:11434"
2
+ DEFAULT_MODEL = "qwen3.5:2b"
@@ -0,0 +1,82 @@
1
+ import json
2
+ import requests
3
+ from postcraftin.config import OLLAMA_HOST, DEFAULT_MODEL
4
+
5
+ POST_LENGTHS = {
6
+ "kurz": {
7
+ "zeichen": "150-500",
8
+ "woerter": "25-85",
9
+ "beschreibung": "Kurzer Post für schnelle Insights und Umfragen"
10
+ },
11
+ "mittel": {
12
+ "zeichen": "1.200-1.500",
13
+ "woerter": "200-260",
14
+ "beschreibung": "Optimaler Bereich für höchstes Engagement"
15
+ },
16
+ "lang": {
17
+ "zeichen": "1.900-2.500",
18
+ "woerter": "320-425",
19
+ "beschreibung": "Tiefergehende Analysen und Case Studies"
20
+ }
21
+ }
22
+
23
+
24
+ def _build_few_shot_block(profile: dict) -> str:
25
+ examples = profile.get("example_posts", [])
26
+ if not examples:
27
+ return ""
28
+ block = "\n\nHier sind Beispiele meines Schreibstils:\n"
29
+ for i, ex in enumerate(examples, 1):
30
+ block += f"\n--- Beispiel {i} ---\n{ex}\n"
31
+ return block
32
+
33
+
34
+ SYSTEM_PROMPT = (
35
+ "Du bist ein professioneller LinkedIn-Content-Schreiber.\n"
36
+ "- Antworte ausschließlich auf Deutsch.\n"
37
+ "- Verwende KEINE Emojis, Emoticons oder Unicode-Symbole (wie ✨, 🚀, 💡, etc.).\n"
38
+ "- Verwende keine englischen Begriffe oder Sätze.\n"
39
+ "- Achte auf korrekte Rechtschreibung und Grammatik.\n"
40
+ "- Schreibe in einem professionellen, seriösen Ton."
41
+ )
42
+
43
+
44
+ def build_prompt(topic: str, profile: dict, post_length: str = "mittel") -> str:
45
+ role = profile.get("role", "Experte")
46
+ audience = profile.get("audience", "Fachleute auf LinkedIn")
47
+ tonality = profile.get("tonality", "inspirierend")
48
+ keywords = ", ".join(profile.get("keywords", []))
49
+ few_shot = _build_few_shot_block(profile)
50
+ length_info = POST_LENGTHS.get(post_length.lower(), POST_LENGTHS["mittel"])
51
+
52
+ prompt = (
53
+ f"{SYSTEM_PROMPT}\n\n"
54
+ f"Ich bin {role} und schreibe für {audience}.\n"
55
+ f"Mein Tonfall ist: {tonality}.\n"
56
+ f"Relevante Keywords: {keywords}.\n"
57
+ f"{few_shot}\n\n"
58
+ f"Schreibe jetzt einen professionellen LinkedIn-Post über das folgende Thema:\n"
59
+ f"{topic}\n\n"
60
+ f"Der Post soll ca. {length_info['zeichen']} Zeichen ({length_info['woerter']} Wörter) umfassen.\n"
61
+ f"Er soll einen starken Hook in den ersten 210 Zeichen haben, klare Absätze und mit einem "
62
+ f"Call-to-Action enden."
63
+ )
64
+ return prompt
65
+
66
+
67
+ def generate_post(topic: str, profile: dict, post_length: str = "mittel", model: str = DEFAULT_MODEL) -> None:
68
+ user_prompt = build_prompt(topic, profile, post_length)
69
+ payload = {
70
+ "model": model,
71
+ "system": SYSTEM_PROMPT,
72
+ "prompt": user_prompt,
73
+ "stream": True,
74
+ }
75
+ url = f"{OLLAMA_HOST}/api/generate"
76
+ with requests.post(url, json=payload, stream=True, timeout=120) as resp:
77
+ resp.raise_for_status()
78
+ for line in resp.iter_lines():
79
+ if line:
80
+ chunk = json.loads(line)
81
+ print(chunk.get("response", ""), end="", flush=True)
82
+ print()
@@ -0,0 +1,58 @@
1
+ import click
2
+ from postcraftin.profile import save_profile
3
+
4
+
5
+ def run_onboarding(profile_name: str) -> None:
6
+ click.echo(click.style("\n🚀 Willkommen bei postcraftin Onboarding!", fg="cyan", bold=True))
7
+ click.echo("Beantworte 5 kurze Fragen, um dein Profil zu erstellen.\n")
8
+
9
+ # Schritt 1: Branche / Rolle
10
+ click.echo(click.style("Schritt 1/5: Branche & Rolle", fg="yellow", bold=True))
11
+ role = click.prompt("In welcher Branche/Rolle bist du tätig?", default="Software Engineer")
12
+
13
+ # Schritt 2: Zielgruppe
14
+ click.echo(click.style("\nSchritt 2/5: Zielgruppe", fg="yellow", bold=True))
15
+ audience = click.prompt("Wen möchtest du mit deinen Posts erreichen?", default="Tech-Professionals auf LinkedIn")
16
+
17
+ # Schritt 3: Tonality
18
+ click.echo(click.style("\nSchritt 3/5: Tonality", fg="yellow", bold=True))
19
+ tonality_choice = click.prompt(
20
+ "Welchen Tonfall bevorzugst du?",
21
+ type=click.Choice(["inspirierend", "sachlich", "provokativ"], case_sensitive=False),
22
+ default="inspirierend",
23
+ )
24
+
25
+ # Schritt 4: Themen-Keywords
26
+ click.echo(click.style("\nSchritt 4/5: Themen-Keywords", fg="yellow", bold=True))
27
+ keywords_raw = click.prompt(
28
+ "Nenne deine wichtigsten Themen-Keywords (kommagetrennt)",
29
+ default="KI, Daten, Architektur",
30
+ )
31
+ keywords = [k.strip() for k in keywords_raw.split(",") if k.strip()]
32
+
33
+ # Schritt 5: Beispiel-Posts
34
+ click.echo(click.style("\nSchritt 5/5: Beispiel-Posts zur Stil-Kalibrierung", fg="yellow", bold=True))
35
+ click.echo("Füge 2 Beispiel-Posts ein, die deinen Stil widerspiegeln.")
36
+ click.echo("(Beende jeden Post mit einer Leerzeile + ENTER + 'END')")
37
+
38
+ example_posts = []
39
+ for i in range(1, 3):
40
+ click.echo(click.style(f"\nBeispiel-Post {i}:", fg="green"))
41
+ lines = []
42
+ while True:
43
+ line = input()
44
+ if line.strip().upper() == "END":
45
+ break
46
+ lines.append(line)
47
+ example_posts.append("\n".join(lines).strip())
48
+
49
+ profile_data = {
50
+ "role": role,
51
+ "audience": audience,
52
+ "tonality": tonality_choice,
53
+ "keywords": keywords,
54
+ "example_posts": example_posts,
55
+ }
56
+
57
+ save_profile(profile_name, profile_data)
58
+ click.echo(click.style(f"\n✅ Profil '{profile_name}' wurde gespeichert!", fg="green", bold=True))
@@ -0,0 +1,33 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ PROFILES_DIR = Path.home() / ".postcraftin" / "profiles"
5
+
6
+
7
+ def _ensure_dir() -> None:
8
+ PROFILES_DIR.mkdir(parents=True, exist_ok=True)
9
+
10
+
11
+ def save_profile(name: str, data: dict) -> None:
12
+ _ensure_dir()
13
+ path = PROFILES_DIR / f"{name}.json"
14
+ with open(path, "w", encoding="utf-8") as f:
15
+ json.dump(data, f, indent=2, ensure_ascii=False)
16
+
17
+
18
+ def load_profile(name: str) -> dict:
19
+ path = PROFILES_DIR / f"{name}.json"
20
+ if not path.exists():
21
+ raise FileNotFoundError(f"Profile '{name}' not found at {path}")
22
+ with open(path, "r", encoding="utf-8") as f:
23
+ return json.load(f)
24
+
25
+
26
+ def list_profiles() -> list[str]:
27
+ _ensure_dir()
28
+ return [p.stem for p in PROFILES_DIR.glob("*.json")]
29
+
30
+
31
+ def get_default_profile_name() -> str | None:
32
+ profiles = list_profiles()
33
+ return profiles[0] if profiles else None
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: postcraftin
3
+ Version: 2.0.1
4
+ Requires-Python: >=3.10
5
+ License-File: LICENSE
6
+ Requires-Dist: requests>=2.31
7
+ Requires-Dist: click>=8.1
8
+ Requires-Dist: rich>=13.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest; extra == "dev"
11
+ Requires-Dist: ruff; extra == "dev"
12
+ Requires-Dist: python-semantic-release>=10.0; extra == "dev"
13
+ Dynamic: license-file
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ postcraftin/__init__.py
5
+ postcraftin/cli.py
6
+ postcraftin/config.py
7
+ postcraftin/generator.py
8
+ postcraftin/onboarding.py
9
+ postcraftin/profile.py
10
+ postcraftin.egg-info/PKG-INFO
11
+ postcraftin.egg-info/SOURCES.txt
12
+ postcraftin.egg-info/dependency_links.txt
13
+ postcraftin.egg-info/entry_points.txt
14
+ postcraftin.egg-info/requires.txt
15
+ postcraftin.egg-info/top_level.txt
16
+ tests/test_smoke.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ postcraftin = postcraftin.cli:cli
@@ -0,0 +1,8 @@
1
+ requests>=2.31
2
+ click>=8.1
3
+ rich>=13.0
4
+
5
+ [dev]
6
+ pytest
7
+ ruff
8
+ python-semantic-release>=10.0
@@ -0,0 +1 @@
1
+ postcraftin
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "postcraftin"
7
+ version = "2.0.1"
8
+ requires-python = ">=3.10"
9
+ dependencies = [
10
+ "requests>=2.31",
11
+ "click>=8.1",
12
+ "rich>=13.0",
13
+ ]
14
+
15
+ [project.optional-dependencies]
16
+ dev = [
17
+ "pytest",
18
+ "ruff",
19
+ "python-semantic-release>=10.0",
20
+ ]
21
+
22
+ [tool.semantic_release]
23
+ version_toml = ["pyproject.toml:project.version"]
24
+ tag_format = "v{version}"
25
+ major_on_zero = false
26
+
27
+ [tool.semantic_release.branches.main]
28
+ match = "(main|master)"
29
+ prerelease = false
30
+
31
+ [tool.semantic_release.commit_parser_options]
32
+ allowed_tags = ["feat", "fix", "perf", "refactor", "style", "test", "docs", "build", "ci", "chore"]
33
+ minor_tags = ["feat"]
34
+ patch_tags = ["fix", "perf"]
35
+
36
+ [project.scripts]
37
+ postcraftin = "postcraftin.cli:cli"
38
+
39
+ [tool.ruff]
40
+ line-length = 120
41
+ exclude = [".venv", "build", "dist", "*.egg-info", ".pytest_cache"]
42
+
43
+ [tool.ruff.lint]
44
+ select = ["E", "F", "W", "I", "D", "C90", "UP036"]
45
+ ignore = ["E203", "D100", "D101", "D102", "D103", "D104", "D403", "I001"]
46
+
47
+ [tool.setuptools.packages.find]
48
+ include = ["postcraftin*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,26 @@
1
+ """Smoke tests for postcraftin core functionality."""
2
+
3
+ from postcraftin.config import OLLAMA_HOST, DEFAULT_MODEL
4
+ from postcraftin.generator import build_prompt
5
+
6
+
7
+ def test_config_defaults():
8
+ """OLLAMA_HOST and DEFAULT_MODEL must be set to non-empty strings."""
9
+ assert OLLAMA_HOST, "OLLAMA_HOST should not be empty"
10
+ assert DEFAULT_MODEL, "DEFAULT_MODEL should not be empty"
11
+ assert OLLAMA_HOST.startswith("http"), "OLLAMA_HOST should be an HTTP URL"
12
+
13
+
14
+ def test_build_prompt_contains_profile_fields():
15
+ """build_prompt() output must contain the topic and key profile fields."""
16
+ topic = "AI in data pipelines"
17
+ profile = {
18
+ "role": "Data Engineer",
19
+ "audience": "Fachleute auf LinkedIn",
20
+ "tonality": "professional",
21
+ "keywords": ["AI", "data"],
22
+ }
23
+ result = build_prompt(topic, profile)
24
+ assert topic in result
25
+ assert profile["role"] in result
26
+ assert profile["tonality"] in result