DEV Community

HYUN SOO LEE
HYUN SOO LEE

Posted on

Building an Automated Korean Saju (四柱) Content Pipeline with Claude Vision + Python

Building an Automated Korean Saju (四柱) Content Pipeline with Claude Vision + Python

The Problem: Dense Structured Images, Zero Machine-Readable Output

Korean Saju (사주, 四柱命理) charts — called Manse-force (萬歲曆) — are dense, visually layered infographics. A single chart image encodes:

  • Four pillars (年柱·月柱·日柱·時柱), each with a Heavenly Stem (天干) and Earthly Branch (地支)
  • Ten-God relationships (十星): Friend (比肩), Robber (劫財), Hurting Officer (傷官), Eating God (食神), Direct Wealth (正財), Indirect Wealth (偏財), Direct Officer (正官), Seven Killings (偏官), Direct Resource (正印), Indirect Resource (偏印)
  • 12-phase cycle markers (十二運星): 건록, 제왕, 태, 양, 관대…
  • Shen-sha (神殺) spirit stars: Goat Blade (羊刃殺), Peach Blossom (桃花殺), Heavenly Noble (天乙貴人), Travel Horse (驛馬殺), Empty Void (空亡), and a dozen more
  • Annual Luck (歲運) and Current Grand Luck (大運) overlays

For a content platform generating hundreds of Saju articles per month across channels — dev.to, Substack, Instagram, YouTube scripts — manually reading each image and writing structured copy is a bottleneck that kills throughput.

This article documents the pipeline we built to solve it: image-in → structured JSON → channel-formatted Markdown/HTML → QA-checked draft, fully automated.


Pipeline Overview

[Chart Image Upload]

[Claude Vision: Manse Extraction Module]

[Structured JSON Schema Validation]

[Prompt Assembly Engine]

[Channel Formatter (dev.to / Substack / IG / YT)]

[QA Gate: Guardrail Checks]

[Draft Output + CTA Injection]

Each stage is a discrete Python module. Let's walk through them.


Stage 1 — Claude Vision: Manse Extraction Module

The hardest part is reliable OCR + semantic extraction from a visually complex chart. Standard OCR (Tesseract, Google Vision) fails on mixed Hanja/Hangul/color-coded circles. Claude Vision handles this well with the right prompt structure.

Extraction Prompt Strategy

We use a two-pass prompt:

Pass 1 — Raw Extraction (no interpretation):

"Extract every visible character from this Manse-force chart image. For each pillar (年柱, 月柱, 日柱, 時柱), list: Heavenly Stem (天干) Hanja character, Earthly Branch (地支) Hanja character, Ten-God label in Korean exactly as written, 12-phase cycle label in Korean exactly as written, and all Shen-sha (神殺) labels exactly as written. Also extract: current Grand Luck (大運) Heavenly Stem, Earthly Branch, Ten-God labels; Annual Luck (歲運) year, Heavenly Stem, Earthly Branch, Ten-God label. Quote image text verbatim — do not infer or translate."

Pass 2 — Schema Mapping:

"Map the extracted values into this JSON schema: { pillars: { year: {...}, month: {...}, day: {...}, hour: {...} }, grand_luck: {...}, annual_luck: {...} }. If a field is ambiguous, set confidence: 'low'."

The verbatim-quote instruction in Pass 1 is critical. Without it, the model normalizes Korean labels — for example, converting a chart's 상관 (Hurting Officer) label to 식신 (Eating God) based on its own interpretation. The chart's printed label must win.

Python Wrapper

import anthropic, base64, json

def extract_manse(image_path: str) -> dict:
client = anthropic.Anthropic()
with open(image_path, "rb") as f:
img_b64 = base64.standard_b64encode(f.read()).decode("utf-8")

pass1 = client.messages.create(
    model="claude-opus-4-5",
    max_tokens=2000,
    messages=[{
        "role": "user",
        "content": [
            {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": img_b64}},
            {"type": "text", "text": EXTRACTION_PROMPT_PASS1}
        ]
    }]
)

pass2 = client.messages.create(
    model="claude-opus-4-5",
    max_tokens=2000,
    messages=[
        {"role": "user", "content": [{"type": "image", ...}, {"type": "text", "text": EXTRACTION_PROMPT_PASS1}]},
        {"role": "assistant", "content": pass1.content[0].text},
        {"role": "user", "content": EXTRACTION_PROMPT_PASS2}
    ]
)

return json.loads(pass2.content[0].text)
Enter fullscreen mode Exit fullscreen mode

Multi-turn context in Pass 2 lets the model reference its own raw extraction without re-reading the image, reducing hallucination drift.


Stage 2 — JSON Schema Validation

Raw Claude output goes through Pydantic validation before any content generation touches it.

from pydantic import BaseModel, field_validator
from typing import Literal, Optional

VALID_TEN_GODS = {"비견","겁재","식신","상관","편재","정재","편관","정관","편인","정인"}
VALID_12PHASE = {"장생","목욕","관대","건록","제왕","쇠","병","사","묘","절","태","양"}

class PillarData(BaseModel):
stem_hanja: str
branch_hanja: str
stem_ten_god: str
branch_ten_god: str
twelve_phase: str
shensha: list[str]

@field_validator("stem_ten_god", "branch_ten_god")
@classmethod
def validate_ten_god(cls, v):
    if v not in VALID_TEN_GODS:
        raise ValueError(f"Invalid Ten-God label: {v}")
    return v
Enter fullscreen mode Exit fullscreen mode

Any confidence: 'low' field triggers a manual review queue rather than proceeding to generation. This is the single most important QA gate — garbage-in-garbage-out is especially painful in a domain where one wrong Ten-God label changes the entire interpretive frame.


Stage 3 — Prompt Assembly Engine

Once the JSON is validated, the Prompt Assembly Engine builds channel-specific generation prompts. Key design decisions:

Guardrail Injections (hardcoded, not LLM-generated)

Every generation prompt includes a static guardrail block:

GUARDRAILS (apply to all output):

  • No absolute predictions (no: "will", "definitely", "certainly", "guaranteed")
  • No gossip, romantic speculation, or personal life assertions
  • No Korean Hangul in output (use English + Hanja notation)
  • Quote chart labels verbatim; do not substitute synonyms
  • End every article with one disclaimer line

These are injected as system-level instructions, not user-turn instructions, so they persist across multi-turn generation calls.

Subject-Matter Anchoring

For group/team analysis content (the most requested content type for K-pop subjects), the prompt includes:

TOPIC FRAME: Analyze the Day Pillar (日柱) as the core identity axis.
Map Ten-God relationships to team-dynamic archetypes:

  • 比肩 (Friend) = peer energy, horizontal collaboration
  • 劫財 (Robber) = competitive drive, resource tension
  • 傷官 (Hurting Officer) = creative disruption, rule-breaking output
  • 正印 (Direct Resource) = institutional support, mentorship reception Annual Luck (歲運) overlay = current-year environmental pressure on the team system.

This anchors the LLM to a consistent analytical framework rather than free-associating.


Stage 4 — Channel Formatter

Different channels need radically different formats from the same JSON source.

Channel Format Word Count Key Constraint
dev.to Markdown + frontmatter ~1500w Technical framing, no fortune-telling
Substack HTML email ~800w Narrative, one key insight
Instagram Caption + hashtags ~150w Hook in line 1, CTA in line 3
YouTube Script with timestamps ~2000w Spoken-word rhythm, no tables

The formatter is a Jinja2 template system. Each channel has a template that receives the same validated JSON + generated prose blocks, then assembles them differently.

from jinja2 import Environment, FileSystemLoader

env = Environment(loader=FileSystemLoader("templates/"))

def format_for_channel(channel: str, data: dict, prose: dict) -> str:
template = env.get_template(f"{channel}.j2")
return template.render(**data, **prose)

The [INFO_GRAPHIC] block — a structured data summary mid-article — is generated as a Markdown table from the validated JSON, not from the LLM, ensuring it is always factually accurate to the chart.


Stage 5 — QA Gate

Before any draft is released to the output queue, it passes through an automated QA check:

QA_RULES = [
("no_absolute", r"\b(will|definitely|certainly|guaranteed|absolutely)\b", "flag"),
("no_hangul", r"[\uAC00-\uD7A3]", "block"),
("has_disclaimer", r"disclaimer|for entertainment|not professional advice", "block"),
("cta_present", r"runartree.com", "flag"),
("word_count", lambda t: 1200 <= len(t.split()) <= 1800, "flag"),
]

def qa_check(text: str) -> list[dict]:
issues = []
for name, rule, severity in QA_RULES:
if callable(rule):
if not rule(text):
issues.append({"rule": name, "severity": severity})
else:
if re.search(rule, text, re.IGNORECASE):
issues.append({"rule": name, "severity": severity, "match": re.search(rule, text).group()})
return issues

block severity halts the pipeline and routes to human review. flag severity logs and continues.

The Hangul check (no_hangul) is particularly important for the dev.to channel — Hangul in a technical article breaks the channel's expected register and confuses non-Korean readers. All Korean concepts must appear as English + Hanja pairs.


[INFO_GRAPHIC] — Sample Extracted Data Structure

The following represents the kind of structured output the pipeline produces from a chart image. Field values are sourced directly from chart image labels, not inferred:

Pillar Stem (天干) Branch (地支) Stem Ten-God Branch Ten-God 12-Phase
年柱 (Year) 偏官 (Seven Killings) 比肩 (Friend) 관대
月柱 (Month) 正印 (Direct Resource) 劫財 (Robber)
日柱 (Day) 正印 (Direct Resource) 제왕
時柱 (Hour) 傷官 (Hurting Officer) 偏印 (Indirect Resource) 건록
大運 (Grand Luck) 偏印 正財
歲運 2026 正印 偏印

Notable Shen-sha flags extracted: 역마살 (Travel Horse, 驛馬殺) on Day and Year pillars; 귀문관살 (Ghost Gate) on Day and Month; 공망 (Empty Void, 空亡) on Grand Luck branch; 도화살 (Peach Blossom, 桃花殺) on Hour and Month.


Lessons Learned

1. Verbatim extraction before interpretation is non-negotiable.
The biggest early failure mode was letting the model interpret and extract simultaneously. Splitting into two passes — raw text out, then schema mapping — dropped label errors by ~70%.

2. Pydantic validation is your ground truth gate.
LLMs are confident when wrong. A closed vocabulary validator (VALID_TEN_GODS, VALID_12PHASE) catches silent errors that would otherwise propagate into published content.

3. Channel framing changes everything.
The same Saju JSON generates a technical pipeline article for dev.to, a reflective newsletter for Substack, and a punchy Instagram caption. The content domain (Saju) is just structured data. The channel strategy is the product.

4. The 反轉 (reversal) insight lives in the Shen-sha layer.
The most engaging content comes from unexpected Shen-sha combinations — for example, 공망 (Empty Void) falling on the Grand Luck branch simultaneously with 역마살 (Travel Horse) suggests a period of high-mobility activity that paradoxically lacks stable grounding. These non-obvious combinations are the ones readers engage with most, and they are only visible when the extraction is precise enough to surface them.

5. Guardrails must be structural, not instructional.
Telling the LLM "don't make predictions" in a user-turn prompt is weak. Injecting it as a system instruction, then running a regex QA check post-generation, is the only reliable approach.


Summary

  • Claude Vision two-pass extraction reliably parses dense Manse-force (萬歲曆) chart images into validated JSON
  • Pydantic schema validation with closed vocabularies gates garbage before it reaches generation
  • Channel-specific Jinja2 templates transform the same structured data into dev.to, Substack, Instagram, and YouTube formats
  • Regex-based QA checks enforce guardrails (no absolute claims, no Hangul, disclaimer present, CTA injected) post-generation
  • The Shen-sha layer — spirit stars like 역마살 (Travel Horse), 공망 (Empty Void), 도화살 (Peach Blossom) — contains the highest-engagement content signals and requires extraction precision to surface

This article describes a content automation pipeline. Saju analysis outputs are for informational and entertainment purposes only and do not constitute professional advice of any kind.


Want to explore Saju-based content tools or commission a custom chart analysis? Visit *runartree.com** for more.*


Project link

This article is based on an automated content workflow for a Korean Saju platform.

The key lesson is simple: generation alone is not enough. A useful publishing pipeline also needs formatting, QA, tracking links, and channel-specific editorial rules.


Bazi interpretation. Not medical, legal, or investment advice.

Top comments (0)