Skip to content
Merged
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
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# 사주 캘린더 · CalDAV

양력 또는 한국 음력 출생 시각에서 사주 네 기둥을 계산하고, 사용자가 만든
조건과 맞는 현재 이후의 실제 날짜·시각을 개인 CalDAV 캘린더로 발행하는
단일 운영자용 서비스입니다.
양력 또는 한국 음력 출생 시각에서 사주 네 기둥을 계산하고, 두 사람에게
고르게 맞는 현재 이후의 실제 날짜·시각이나 사용자가 만든 조건의 일치 시간을
개인 CalDAV 캘린더로 발행하는 단일 운영자용 서비스입니다.

> 문화·역법 참고 도구입니다. 운세의 과학적 타당성이나 미래 사건과의
> 인과관계를 주장하지 않습니다.
Expand All @@ -11,6 +11,10 @@

- 양력·한국 음력·윤달 출생 입력과 계산용 양력 시각의 분리 보존
- 출생 프로필별 년주·월주·일주·시주 계산과 한글 뜻 우선 설명
- 두 사람의 출생 정보를 한 화면에 입력해 가까운 날짜별 추천 시간 확인
- 한 사람에게만 유리한 후보를 줄이고, 두 사람의 일지와 날짜·시간 지지 관계를
함께 보여 주는 설명 가능한 문화적 점수
- 추천 결과를 별도 CalDAV 캘린더로 저장하고 반복 동기화
- 출생 도시에서 IANA 시간대를 자동 선택하고 좌표 입력 없이 표준시 계산
- 선택한 도시의 서버 내부 경도를 사용하는 진태양시 보정 선택
- 일주·시주의 천간, 지지, 오행을 조합하는 허용 목록 기반 규칙
Expand All @@ -20,6 +24,12 @@
- HTTP Basic으로 보호되는 한국어 운영자 웹 콘솔
- SQLite 메타데이터와 소유자 전용 CalDAV 저장소

첫 화면의 `두 사람의 좋은 시간 찾기`에서 저장된 프로필을 선택하거나 두 사람의
생년월일과 탄생 시를 바로 입력하면 됩니다. 기본 검색은 프로필 시간대의 지금
이후부터 1년 안에서 날짜마다 가장 균형이 높은 시간 하나를 골라 가까운 순으로
보여 줍니다. 더 세밀한 천간·지지 조건은 아래의 `고급: 직접 조건 만들기`에서
계속 사용할 수 있습니다.
Comment on lines +30 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

UI에 없는 라벨을 인용하고 있습니다.

app/static/index.html Line 275-276의 실제 문구는 “고급 기능 · 명식과 조건을 직접 골라 캘린더 만들기”입니다. README의 고급: 직접 조건 만들기와 일치하지 않아 사용자가 해당 항목을 찾기 어렵습니다.

📝 제안 수정
-보여 줍니다. 더 세밀한 천간·지지 조건은 아래의 `고급: 직접 조건 만들기`에서
+보여 줍니다. 더 세밀한 천간·지지 조건은 아래의 `고급 기능`에서
 계속 사용할 수 있습니다.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
보여 줍니다. 더 세밀한 천간·지지 조건은 아래의 `고급: 직접 조건 만들기`에서
계속 사용할 수 있습니다.
보여 줍니다. 더 세밀한 천간·지지 조건은 아래의 `고급 기능`에서
계속 사용할 수 있습니다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 30 - 31, README의 해당 안내 문구에서 UI에 표시되지 않는 `고급: 직접 조건
만들기` 라벨을 제거하고, index.html의 실제 라벨인 `고급 기능 · 명식과 조건을 직접 골라 캘린더 만들기`로 교체하세요.


## 빠른 시작

Python 3.12 이상과 [uv](https://docs.astral.sh/uv/)를 사용합니다.
Expand Down
203 changes: 203 additions & 0 deletions app/compatibility.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
"""Transparent, culturally grounded two-person date ranking."""

from __future__ import annotations

from dataclasses import dataclass
from datetime import date, datetime

from app.events import MatchingWindow, iter_chart_windows
from app.saju import Chart

SIX_HARMONIES = (
frozenset(("子", "丑")),
frozenset(("寅", "亥")),
frozenset(("卯", "戌")),
frozenset(("辰", "酉")),
frozenset(("巳", "申")),
frozenset(("午", "未")),
)
THREE_HARMONIES = (
frozenset(("申", "子", "辰")),
frozenset(("亥", "卯", "未")),
frozenset(("寅", "午", "戌")),
frozenset(("巳", "酉", "丑")),
)
SIX_CLASHES = (
frozenset(("子", "午")),
frozenset(("丑", "未")),
frozenset(("寅", "申")),
frozenset(("卯", "酉")),
frozenset(("辰", "戌")),
frozenset(("巳", "亥")),
)

DAY_POINTS = {
"six_harmony": 24,
"three_harmony": 16,
"same": 8,
"neutral": 0,
"clash": -32,
}
HOUR_POINTS = {
"six_harmony": 12,
"three_harmony": 8,
"same": 4,
"neutral": 0,
"clash": -16,
}
MINIMUM_SCORE = 60


@dataclass(frozen=True, slots=True)
class CompatibilityCandidate:
window: MatchingWindow
score: int
primary_score: int
secondary_score: int
label: str
reasons: tuple[str, ...]


def _relation(left: str, right: str) -> str:
if left == right:
return "same"
pair = frozenset((left, right))
if pair in SIX_HARMONIES:
return "six_harmony"
if any(pair <= group for group in THREE_HARMONIES):
return "three_harmony"
if pair in SIX_CLASHES:
return "clash"
return "neutral"


def _bounded_score(day_relation: str, hour_relation: str) -> int:
return max(
0,
min(100, 50 + DAY_POINTS[day_relation] + HOUR_POINTS[hour_relation]),
)


def _reason(person: str, period: str, relation: str) -> str | None:
relation_labels = {
"six_harmony": "서로 짝을 이루는 육합",
"three_harmony": "같은 흐름을 이루는 삼합 계열",
"same": "같은 지지",
}
if relation not in relation_labels:
return None
return f"{period}의 기운이 {person}의 일지와 {relation_labels[relation]}입니다."


def _label(score: int) -> str:
if score >= 80:
return "두 사람 모두에게 조화가 큰 시간"
if score >= 70:
return "두 사람에게 고르게 잘 맞는 시간"
return "두 사람에게 무난하게 어울리는 시간"


def score_window(
window: MatchingWindow,
primary: Chart,
secondary: Chart,
primary_name: str,
secondary_name: str,
) -> CompatibilityCandidate | None:
"""Score one window without presenting the convention as a prediction."""

current_day = window.chart.day.branch
current_hour = window.chart.hour.branch
primary_day_relation = _relation(current_day, primary.day.branch)
secondary_day_relation = _relation(current_day, secondary.day.branch)
primary_hour_relation = _relation(current_hour, primary.day.branch)
secondary_hour_relation = _relation(current_hour, secondary.day.branch)
relations = (
primary_day_relation,
secondary_day_relation,
primary_hour_relation,
secondary_hour_relation,
)
if "clash" in relations:
return None

primary_score = _bounded_score(primary_day_relation, primary_hour_relation)
secondary_score = _bounded_score(secondary_day_relation, secondary_hour_relation)
lower = min(primary_score, secondary_score)
average = (primary_score + secondary_score) / 2
score = round(lower * 0.7 + average * 0.3)
if score < MINIMUM_SCORE or all(relation == "neutral" for relation in relations):
return None

reasons = tuple(
reason
for reason in (
_reason(primary_name, "이 날짜", primary_day_relation),
_reason(secondary_name, "이 날짜", secondary_day_relation),
_reason(primary_name, "이 시간", primary_hour_relation),
_reason(secondary_name, "이 시간", secondary_hour_relation),
)
if reason is not None
)
return CompatibilityCandidate(
window=window,
score=score,
primary_score=primary_score,
secondary_score=secondary_score,
label=_label(score),
reasons=reasons,
)


def generate_compatibility_candidates(
primary: Chart,
secondary: Chart,
primary_name: str,
secondary_name: str,
start_date: date,
end_date: date,
timezone: str,
time_mode: str,
longitude: float | None,
limit: int = 24,
not_before: datetime | None = None,
) -> list[CompatibilityCandidate]:
"""Return the highest-scoring candidate per day, breaking ties by start time."""

if not 1 <= limit <= 96:
raise ValueError("limit must be between 1 and 96")

best_by_date: dict[date, CompatibilityCandidate] = {}
for window in iter_chart_windows(
start_date,
end_date,
timezone,
time_mode,
longitude,
):
if not_before is not None and window.end <= not_before:
continue
candidate = score_window(
window,
primary,
secondary,
primary_name,
secondary_name,
)
if candidate is None:
continue
candidate_date = candidate.window.start.date()
previous = best_by_date.get(candidate_date)
if previous is None or (
candidate.score > previous.score
or (
candidate.score == previous.score
and candidate.window.start < previous.window.start
)
):
best_by_date[candidate_date] = candidate

return sorted(
best_by_date.values(),
key=lambda candidate: candidate.window.start,
)[:limit]
87 changes: 74 additions & 13 deletions app/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@

from __future__ import annotations

from collections.abc import Iterator
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta
from zoneinfo import ZoneInfo

from app.rules import Rule, matches
from app.saju import Chart, _true_solar_time, calculate_chart
from app.saju import Chart, _hour_pillar, _true_solar_time, calculate_chart

SEGMENTS = ((0, 1),) + tuple((hour, hour + 2) for hour in range(1, 22, 2)) + ((23, 24),)

Expand Down Expand Up @@ -48,32 +49,92 @@ def generate_windows(
) -> list[MatchingWindow]:
"""Return matching local-time intervals, inclusive of both boundary dates."""

return [
window
for window in iter_chart_windows(
start_date,
end_date,
timezone,
time_mode,
longitude,
)
if matches(rule, natal=natal, current=window.chart)
]


def iter_chart_windows(
start_date: date,
end_date: date,
timezone: str,
time_mode: str,
longitude: float | None,
) -> Iterator[MatchingWindow]:
"""Yield every local double-hour window with its calculated chart."""

if end_date < start_date:
raise ValueError("end_date must not be before start_date")
if (end_date - start_date).days + 1 > 730:
raise ValueError("generation range must not exceed 730 days")

zone = ZoneInfo(timezone)
windows: list[MatchingWindow] = []
for day_offset in range((end_date - start_date).days + 1):
calculation_date = start_date + timedelta(days=day_offset)
midnight = datetime.combine(calculation_date, time.min)
end_of_day = midnight + timedelta(days=1, microseconds=-1)
civil_midnight = _civil_from_calculation_time(
midnight,
zone,
time_mode,
longitude,
)
civil_end_of_day = _civil_from_calculation_time(
end_of_day,
zone,
time_mode,
longitude,
)
start_chart = calculate_chart(civil_midnight, timezone, time_mode, longitude)
end_chart = calculate_chart(civil_end_of_day, timezone, time_mode, longitude)
has_pillar_transition = (
start_chart.year != end_chart.year
or start_chart.month != end_chart.month
)
for start_hour, end_hour in SEGMENTS:
calculation_start = midnight + timedelta(hours=start_hour)
calculation_end = midnight + timedelta(hours=end_hour)
civil_start = _civil_from_calculation_time(
calculation_start, zone, time_mode, longitude
)
civil_end = _civil_from_calculation_time(calculation_end, zone, time_mode, longitude)
civil_midpoint = civil_start + (civil_end - civil_start) / 2
current = calculate_chart(civil_midpoint, timezone, time_mode, longitude)
if matches(rule, natal=natal, current=current):
windows.append(
MatchingWindow(
start=civil_start.replace(tzinfo=zone),
end=civil_end.replace(tzinfo=zone),
chart=current,
)
calculation_midpoint = calculation_start + (
calculation_end - calculation_start
) / 2
if has_pillar_transition:
civil_midpoint = _civil_from_calculation_time(
calculation_midpoint,
zone,
time_mode,
longitude,
)
return windows

current = calculate_chart(
civil_midpoint,
timezone,
time_mode,
longitude,
)
else:
current = Chart(
year=start_chart.year,
month=start_chart.month,
day=start_chart.day,
hour=_hour_pillar(
start_chart.day.stem,
calculation_midpoint.hour,
),
calculation_local=calculation_midpoint,
)
yield MatchingWindow(
start=civil_start.replace(tzinfo=zone),
end=civil_end.replace(tzinfo=zone),
chart=current,
)
Loading
Loading