Files
mini_tp/app/smt/service/SmokeAiService.php
T
2026-04-26 09:24:08 +08:00

807 lines
32 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\smt\service;
use app\smt\model\SmokeAIAdvice;
use app\smt\model\SmokeAIAdviceUnlock;
use app\smt\model\SmokeAINextSmoke;
use app\smt\model\SmokeLog;
use app\smt\model\SmokeUserProfile;
use app\smt\model\UserMembership;
use DateInterval;
use DateTimeImmutable;
use think\facade\Db;
class SmokeAiService
{
public const TYPE_DAILY_ADVICE = 'daily_advice';
public const TYPE_NEXT_SMOKE = 'next_smoke_time';
public const TYPE_DAILY_SUMMARY = 'daily_summary';
public function getOrGenerateAdvice(array $user, DateTimeImmutable $adviceDate, string $promptVersion = 'v2'): array
{
$cached = $this->getCachedByType((int) $user['id'], self::TYPE_DAILY_ADVICE, $adviceDate, $promptVersion);
if ($cached) {
return $cached;
}
if (!$this->isAllowed($user, $adviceDate)) {
throw new \RuntimeException('需要会员或观看广告解锁后才可生成建议', 403);
}
[$snapshot, $snapshotJson] = $this->buildSnapshot((int) $user['id'], $adviceDate);
[$content, $meta] = $this->generateAdviceText($snapshot);
return $this->saveAdviceRecord(
(int) $user['id'],
self::TYPE_DAILY_ADVICE,
$adviceDate,
$promptVersion,
$snapshotJson,
$content,
$meta
);
}
public function unlock(array $user, DateTimeImmutable $unlockDate): array
{
$unlockDate = Support::dateOnly($unlockDate);
$now = Support::now();
$existing = SmokeAIAdviceUnlock::where('uid', (int) $user['id'])
->where('unlock_date', $unlockDate->format(Support::DATE_LAYOUT))
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
->find();
if (!$existing) {
$existing = new SmokeAIAdviceUnlock();
$existing->uid = (int) $user['id'];
$existing->unlock_date = $unlockDate->format(Support::DATE_LAYOUT);
$existing->createtime = $now->getTimestamp();
}
$existing->ad_watched_at = $now->format(Support::DATETIME_LAYOUT);
$existing->updatetime = $now->getTimestamp();
$existing->save();
return [
'unlocked' => true,
'date' => $unlockDate->format(Support::DATE_LAYOUT),
];
}
public function getCachedByType(int $userId, string $type, DateTimeImmutable $date, string $promptVersion): ?array
{
$row = SmokeAIAdvice::where('uid', $userId)
->where('type', $type)
->where('advice_date', Support::dateOnly($date)->format(Support::DATE_LAYOUT))
->where('prompt_version', $promptVersion)
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
->find();
if (!$row) {
return null;
}
return $this->formatAdviceRecord($row->toArray());
}
public function getOrGenerateDailySummary(array $user, DateTimeImmutable $summaryDate, string $promptVersion = 'v1'): array
{
$cached = $this->getCachedByType((int) $user['id'], self::TYPE_DAILY_SUMMARY, $summaryDate, $promptVersion);
if ($cached) {
return $cached;
}
if (!$this->isAllowed($user, $summaryDate)) {
throw new \RuntimeException('需要会员或观看广告解锁后才可生成总结', 403);
}
[$snapshot, $snapshotJson] = $this->buildSnapshot((int) $user['id'], $summaryDate, true);
[$content, $meta] = $this->generateDailySummaryText($snapshot);
return $this->saveAdviceRecord(
(int) $user['id'],
self::TYPE_DAILY_SUMMARY,
$summaryDate,
$promptVersion,
$snapshotJson,
$content,
$meta
);
}
public function getCachedNextSmoke(array $user, DateTimeImmutable $planDate, string $promptVersion = 'v1'): ?array
{
$advice = SmokeAIAdvice::where('uid', (int) $user['id'])
->where('type', self::TYPE_NEXT_SMOKE)
->where('advice_date', Support::dateOnly($planDate)->format(Support::DATE_LAYOUT))
->where('prompt_version', $promptVersion)
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
->order('id', 'desc')
->find();
if (!$advice) {
return null;
}
$suggestion = $this->buildNextSuggestionFromCache($advice);
if ($this->shouldRefreshNextCache($suggestion, $planDate)) {
return null;
}
return $suggestion;
}
public function getOrGenerateNextSmoke(
array $user,
DateTimeImmutable $asOf,
DateTimeImmutable $planDate,
string $promptVersion,
array $defaultSuggestion
): array {
$planDate = Support::dateOnly($planDate);
$cached = $this->getCachedNextSmoke($user, $planDate, $promptVersion);
if ($cached) {
return $cached;
}
if (!$this->isAllowed($user, $planDate)) {
throw new \RuntimeException('需要观看广告解锁后才可生成', 403);
}
$recent3Days = $this->loadRecent3Days((int) $user['id'], $planDate);
$profile = $this->loadProfileContext((int) $user['id']);
[$content, $nodes, $notBeforeAt, $suggestedAt, $meta] = $this->generateNextSmokeSuggestion($asOf, $planDate, $defaultSuggestion, $recent3Days, $profile);
$advice = SmokeAIAdvice::where('uid', (int) $user['id'])
->where('type', self::TYPE_NEXT_SMOKE)
->where('advice_date', $planDate->format(Support::DATE_LAYOUT))
->where('prompt_version', $promptVersion)
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
->find();
$snapshot = [
'as_of' => $asOf->format(DATE_ATOM),
'plan_date' => $planDate->format(Support::DATE_LAYOUT),
'default_suggestion' => $defaultSuggestion,
'profile' => $profile,
'recent_3_days' => $recent3Days,
];
$snapshotJson = json_encode($snapshot, JSON_UNESCAPED_UNICODE);
$now = time();
if (!$advice) {
$advice = new SmokeAIAdvice();
$advice->uid = (int) $user['id'];
$advice->type = self::TYPE_NEXT_SMOKE;
$advice->advice_date = $planDate->format(Support::DATE_LAYOUT);
$advice->prompt_version = $promptVersion;
$advice->createtime = $now;
}
$advice->provider = $meta['provider'];
$advice->model = $meta['model'];
$advice->input_snapshot = $snapshotJson;
$advice->advice = $content;
$advice->tokens_in = $meta['tokens_in'];
$advice->tokens_out = $meta['tokens_out'];
$advice->updatetime = $now;
$advice->save();
SmokeAINextSmoke::where('ai_advice_id', (int) $advice->id)->delete();
$this->saveNextNodes((int) $user['id'], (int) $advice->id, $planDate, $notBeforeAt, $suggestedAt, $nodes);
return [
'plan_date' => $planDate->format(Support::DATE_LAYOUT),
'not_before_at' => $notBeforeAt->format(DATE_ATOM),
'suggested_at' => $suggestedAt->format(DATE_ATOM),
'time_nodes' => $nodes,
'advice' => $content,
'prompt_version' => $promptVersion,
'model' => $meta['model'],
'provider' => $meta['provider'],
];
}
private function saveAdviceRecord(
int $userId,
string $type,
DateTimeImmutable $date,
string $promptVersion,
string $snapshotJson,
string $content,
array $meta
): array {
$now = time();
$row = new SmokeAIAdvice();
$row->uid = $userId;
$row->type = $type;
$row->advice_date = Support::dateOnly($date)->format(Support::DATE_LAYOUT);
$row->prompt_version = $promptVersion;
$row->provider = $meta['provider'];
$row->model = $meta['model'];
$row->input_snapshot = $snapshotJson;
$row->advice = $content;
$row->tokens_in = $meta['tokens_in'];
$row->tokens_out = $meta['tokens_out'];
$row->createtime = $now;
$row->updatetime = $now;
$row->save();
return $this->formatAdviceRecord($row->toArray());
}
private function formatAdviceRecord(array $row): array
{
return [
'id' => (int) ($row['id'] ?? 0),
'uid' => (int) ($row['uid'] ?? 0),
'type' => (string) ($row['type'] ?? ''),
'date' => (string) ($row['advice_date'] ?? ''),
'advice_date' => (string) ($row['advice_date'] ?? ''),
'prompt_version' => (string) ($row['prompt_version'] ?? ''),
'provider' => (string) ($row['provider'] ?? ''),
'model' => (string) ($row['model'] ?? ''),
'advice' => (string) ($row['advice'] ?? ''),
'content' => (string) ($row['advice'] ?? ''),
];
}
private function buildSnapshot(int $userId, DateTimeImmutable $date, bool $todayMessage = false): array
{
$rows = Db::connect('mysql')->name('fa_smoke_log')
->where('uid', $userId)
->where('smoke_time', Support::dateOnly($date)->format(Support::DATE_LAYOUT))
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
->orderRaw('COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) ASC')
->order('id', 'asc')
->select()
->toArray();
if (empty($rows)) {
throw new \RuntimeException($todayMessage ? '今天还没有抽烟记录,无法生成总结' : '该日期没有抽烟记录,无法生成建议', 400);
}
$total = 0;
$nodes = [];
foreach ($rows as $row) {
$total += (int) ($row['num'] ?? 0);
$eventAt = Support::logEventAt($row);
$nodes[] = [
'time' => $eventAt ? $eventAt->format('H:i') : '',
'num' => (int) ($row['num'] ?? 0),
'level' => (int) ($row['level'] ?? 1),
'remark' => (string) ($row['remark'] ?? ''),
];
}
$profile = $this->loadProfileContext($userId);
$snapshot = [
'date' => Support::dateOnly($date)->format(Support::DATE_LAYOUT),
'total_num' => $total,
'nodes' => $nodes,
'profile' => $profile,
];
return [$snapshot, json_encode($snapshot, JSON_UNESCAPED_UNICODE)];
}
private function loadProfileContext(int $userId): ?array
{
$profile = SmokeUserProfile::findByUid($userId);
if (!$profile) {
return null;
}
$wakeUpTime = trim((string) $profile->wake_up_time);
$sleepTime = trim((string) $profile->sleep_time);
$awakeMinutes = 16 * 60;
try {
$awakeMinutes = Support::awakeMinutes($wakeUpTime, $sleepTime);
} catch (\Throwable $e) {
}
return [
'baseline_cigs_per_day' => (int) $profile->baseline_cigs_per_day,
'smoking_years' => (float) $profile->smoking_years,
'pack_price_cent' => (int) $profile->pack_price_cent,
'smoke_motivations' => Support::jsonArray($profile->smoke_motivations),
'quit_motivations' => Support::jsonArray($profile->quit_motivations),
'wake_up_time' => $wakeUpTime,
'sleep_time' => $sleepTime,
'awake_minutes' => $awakeMinutes,
'baseline_interval_minutes' => Support::baselineIntervalMinutes($awakeMinutes, (int) $profile->baseline_cigs_per_day),
'user_segment' => Support::deriveUserSegment((int) $profile->baseline_cigs_per_day, (float) $profile->smoking_years),
];
}
private function isAllowed(array $user, DateTimeImmutable $date): bool
{
return $this->hasActiveMembership($user) || $this->isUnlocked((int) $user['id'], $date);
}
private function hasActiveMembership(array $user): bool
{
$count = UserMembership::where('mini_program_id', (int) $user['mini_program_id'])
->where('user_id', (int) $user['id'])
->where('status', 'active')
->where('ends_at', '>', Support::now()->format(Support::DATETIME_LAYOUT))
->whereNull('deleted_at')
->count();
return (int) $count > 0;
}
private function isUnlocked(int $userId, DateTimeImmutable $date): bool
{
return SmokeAIAdviceUnlock::where('uid', $userId)
->where('unlock_date', Support::dateOnly($date)->format(Support::DATE_LAYOUT))
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
->find() !== null;
}
private function generateAdviceText(array $snapshot): array
{
$fallback = $this->buildFallbackAdvice($snapshot);
if (!$this->hasAiConfig()) {
return [$fallback, ['provider' => 'built-in', 'model' => 'rule-based', 'tokens_in' => null, 'tokens_out' => null]];
}
$systemPrompt = trim('你是一名专业的戒烟教练。请基于用户昨天的抽烟记录,输出中文建议:先给出 1-3 条模式分析,再给出至少 5 条今天的具体行动建议,最后补充一个 60 秒顶住烟瘾的应对流程。');
$userPrompt = '用户昨日数据(JSON:' . PHP_EOL . json_encode($snapshot, JSON_UNESCAPED_UNICODE);
try {
$resp = $this->callChat([
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userPrompt],
]);
return [$resp['content'], $resp];
} catch (\Throwable $e) {
return [$fallback, ['provider' => 'built-in', 'model' => 'rule-based', 'tokens_in' => null, 'tokens_out' => null]];
}
}
private function generateDailySummaryText(array $snapshot): array
{
$fallback = $this->buildFallbackDailySummary($snapshot);
if (!$this->hasAiConfig()) {
return [$fallback, ['provider' => 'built-in', 'model' => 'rule-based', 'tokens_in' => null, 'tokens_out' => null]];
}
$systemPrompt = trim('你是一名专业的戒烟教练。请严格输出 JSON,字段为 summary、highlights、suggestion。内容基于用户当天抽烟数据,语气鼓励。');
$userPrompt = '用户今日数据(JSON:' . PHP_EOL . json_encode($snapshot, JSON_UNESCAPED_UNICODE);
try {
$resp = $this->callChat([
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userPrompt],
]);
$jsonText = $this->extractJson($resp['content']);
if ($jsonText !== '') {
return [$jsonText, $resp];
}
} catch (\Throwable $e) {
}
return [$fallback, ['provider' => 'built-in', 'model' => 'rule-based', 'tokens_in' => null, 'tokens_out' => null]];
}
private function generateNextSmokeSuggestion(
DateTimeImmutable $asOf,
DateTimeImmutable $planDate,
array $defaultSuggestion,
array $recent3Days,
?array $profile
): array {
[$fallbackContent, $fallbackNodes, $fallbackNotBeforeAt, $fallbackSuggestedAt] = $this->buildFallbackNextSmoke($asOf, $planDate, $defaultSuggestion, $profile);
if (!$this->hasAiConfig()) {
return [$fallbackContent, $fallbackNodes, $fallbackNotBeforeAt, $fallbackSuggestedAt, ['provider' => 'built-in', 'model' => 'rule-based', 'tokens_in' => null, 'tokens_out' => null]];
}
$input = [
'as_of' => $asOf->format(DATE_ATOM),
'plan_date' => $planDate->format(Support::DATE_LAYOUT),
'default_suggestion' => $defaultSuggestion,
'profile' => $profile,
'recent_3_days' => $recent3Days,
];
$systemPrompt = trim('你是一名专业的戒烟教练。请严格输出 JSONnot_before_at、suggested_at、time_nodes、advice。所有时间必须属于 plan_datetime_nodes 用 HH:MM。');
$userPrompt = '输入(JSON):' . PHP_EOL . json_encode($input, JSON_UNESCAPED_UNICODE);
try {
$resp = $this->callChat([
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userPrompt],
]);
$jsonText = $this->extractJson($resp['content']);
if ($jsonText !== '') {
$decoded = json_decode($jsonText, true);
if (is_array($decoded)) {
$notBeforeAt = $this->parseFlexibleTime((string) ($decoded['not_before_at'] ?? ''), $planDate, $fallbackNotBeforeAt);
$suggestedAt = $this->parseFlexibleTime((string) ($decoded['suggested_at'] ?? ''), $planDate, $fallbackSuggestedAt);
if ($suggestedAt < $notBeforeAt) {
$suggestedAt = $notBeforeAt;
}
$nodes = $this->normalizeNodes($decoded['time_nodes'] ?? [], $planDate, $notBeforeAt, $profile);
return [
trim((string) ($decoded['advice'] ?? $fallbackContent)),
$nodes,
$notBeforeAt,
$suggestedAt,
$resp,
];
}
}
} catch (\Throwable $e) {
}
return [$fallbackContent, $fallbackNodes, $fallbackNotBeforeAt, $fallbackSuggestedAt, ['provider' => 'built-in', 'model' => 'rule-based', 'tokens_in' => null, 'tokens_out' => null]];
}
private function buildFallbackAdvice(array $snapshot): string
{
$total = (int) ($snapshot['total_num'] ?? 0);
$nodes = $snapshot['nodes'] ?? [];
$first = $nodes[0]['time'] ?? '';
$last = $nodes[count($nodes) - 1]['time'] ?? '';
$quitMotivation = $snapshot['profile']['quit_motivations'][0] ?? '把状态调整回来';
$lines = [
sprintf('昨天你一共记录了 %d 支烟。%s%s', $total, $first !== '' ? '第一支大约在 ' . $first . '' : '', $last !== '' ? '最后一支在 ' . $last . '。' : ''),
'今天先盯住最容易失守的一个时段,把第一根或最顺手的一根至少延后 10 分钟。',
'每次想抽的时候先喝半杯水,站起来走 30-60 秒,再决定要不要点烟。',
'如果是饭后或社交触发,提前准备口香糖、无糖饮料或离开吸烟环境 3 分钟。',
'把今天的目标改成“少一支也算赢”,不要追求一次性完美。',
sprintf('情绪上来时,重复提醒自己:%s。', $quitMotivation),
'60 秒应对流程:先深呼吸 4 次,然后喝水 3 口,再拖延 1 分钟,通常烟瘾峰值会先过去。',
];
return implode("\n", $lines);
}
private function buildFallbackDailySummary(array $snapshot): string
{
$total = (int) ($snapshot['total_num'] ?? 0);
$nodes = $snapshot['nodes'] ?? [];
$times = array_values(array_filter(array_map(static function ($item) {
return trim((string) ($item['time'] ?? ''));
}, $nodes)));
$highlights = [];
if (!empty($times)) {
$highlights[] = sprintf('今天首支烟时间大约在 %s,最后一次记录在 %s。', $times[0], $times[count($times) - 1]);
}
$highlights[] = sprintf('今日总量为 %d 支,后续可重点观察最容易连续抽烟的时段。', $total);
$highlights[] = '如果明天只盯住一个节点,优先尝试把最早或最顺手的一支往后拖 10 分钟。';
return json_encode([
'summary' => sprintf('今天共记录 %d 支烟,整体节奏已经被你清楚地记录下来,这本身就是建立改变的第一步。接下来重点不是苛责自己,而是抓住一个最容易多抽的时段做微调。', $total),
'highlights' => array_slice($highlights, 0, 3),
'suggestion' => '明天先只做一个动作:把最容易点上的那一支延后 10 分钟,并在这段时间用喝水或起身走动替代。',
], JSON_UNESCAPED_UNICODE);
}
private function buildFallbackNextSmoke(
DateTimeImmutable $asOf,
DateTimeImmutable $planDate,
array $defaultSuggestion,
?array $profile
): array {
$notBeforeAt = !empty($defaultSuggestion['next_smoke_at'])
? Support::toDateTime((string) $defaultSuggestion['next_smoke_at'])
: $asOf->add(new DateInterval('PT5M'));
if (Support::dateOnly($planDate) > Support::dateOnly($asOf)) {
$notBeforeAt = Support::dateOnly($planDate)->setTime(7, 0);
if ($profile && !empty($profile['wake_up_time'])) {
$wakeMin = Support::parseHHMM((string) $profile['wake_up_time']);
$notBeforeAt = Support::dateOnly($planDate)->setTime(intdiv($wakeMin, 60), $wakeMin % 60);
}
}
$suggestedAt = $notBeforeAt;
$interval = max(20, min(120, (int) ($defaultSuggestion['interval_minutes'] ?? 60)));
$nodes = [];
$cursor = $suggestedAt;
for ($i = 0; $i < 4; $i++) {
if (Support::dateOnly($cursor) != Support::dateOnly($planDate)) {
break;
}
$nodes[] = $cursor->format('H:i');
$cursor = $cursor->add(new DateInterval('PT' . max(15, (int) round($interval * 0.7)) . 'M'));
}
$content = sprintf('先按默认节奏走,建议至少等到 %s。如果这段时间烟瘾明显上来,先用喝水、深呼吸或短暂走动顶一轮,再决定要不要抽。', $suggestedAt->format('H:i'));
return [$content, array_values(array_unique($nodes)), $notBeforeAt, $suggestedAt];
}
private function loadRecent3Days(int $userId, DateTimeImmutable $planDate): array
{
$today = Support::dateOnly();
$end = $planDate > $today ? $today : $planDate;
$start = $end->modify('-2 day');
$rows = Db::connect('mysql')->name('fa_smoke_log')
->where('uid', $userId)
->whereBetween('smoke_time', [$start->format(Support::DATE_LAYOUT), $end->format(Support::DATE_LAYOUT)])
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
->order('smoke_time', 'asc')
->orderRaw('COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) ASC')
->order('id', 'asc')
->select()
->toArray();
$grouped = [];
foreach ($rows as $row) {
$day = (string) ($row['smoke_time'] ?? '');
if (!isset($grouped[$day])) {
$grouped[$day] = ['date' => $day, 'total_num' => 0, 'resisted_count' => 0, 'nodes' => []];
}
$isResisted = (int) ($row['level'] ?? 1) === 0 && (int) ($row['num'] ?? 1) === 0;
if ($isResisted) {
$grouped[$day]['resisted_count']++;
} else {
$grouped[$day]['total_num'] += (int) ($row['num'] ?? 0);
}
$eventAt = Support::logEventAt($row);
$grouped[$day]['nodes'][] = [
'time' => $eventAt ? $eventAt->format('H:i') : '',
'num' => (int) ($row['num'] ?? 0),
'level' => (int) ($row['level'] ?? 1),
'is_resisted' => $isResisted,
'remark' => Support::truncate((string) ($row['remark'] ?? ''), 80),
];
}
$result = [];
for ($cursor = $start; $cursor <= $end; $cursor = $cursor->add(new DateInterval('P1D'))) {
$key = $cursor->format(Support::DATE_LAYOUT);
$result[] = $grouped[$key] ?? ['date' => $key, 'total_num' => 0, 'resisted_count' => 0, 'nodes' => []];
}
return $result;
}
private function saveNextNodes(int $userId, int $adviceId, DateTimeImmutable $planDate, DateTimeImmutable $notBeforeAt, DateTimeImmutable $suggestedAt, array $nodes): void
{
$rows = [
[
'uid' => $userId,
'plan_date' => $planDate->format(Support::DATE_LAYOUT),
'ai_advice_id' => $adviceId,
'node_type' => 'not_before',
'node_at' => $notBeforeAt->format(Support::DATETIME_LAYOUT),
'createtime' => time(),
'updatetime' => time(),
],
[
'uid' => $userId,
'plan_date' => $planDate->format(Support::DATE_LAYOUT),
'ai_advice_id' => $adviceId,
'node_type' => 'suggested',
'node_at' => $suggestedAt->format(Support::DATETIME_LAYOUT),
'createtime' => time(),
'updatetime' => time(),
],
];
foreach ($nodes as $node) {
$nodeAt = $this->parseFlexibleTime((string) $node, $planDate, $suggestedAt);
$rows[] = [
'uid' => $userId,
'plan_date' => $planDate->format(Support::DATE_LAYOUT),
'ai_advice_id' => $adviceId,
'node_type' => 'node',
'node_at' => $nodeAt->format(Support::DATETIME_LAYOUT),
'createtime' => time(),
'updatetime' => time(),
];
}
Db::connect('mysql')->name('fa_smoke_ai_next_smoke')->insertAll($rows);
}
private function buildNextSuggestionFromCache(SmokeAIAdvice $advice): array
{
$rows = SmokeAINextSmoke::where('ai_advice_id', (int) $advice->id)
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
->order('node_at', 'asc')
->select()
->all();
$notBeforeAt = '';
$suggestedAt = '';
$nodes = [];
foreach ($rows as $row) {
if ((string) $row->node_type === 'not_before') {
$notBeforeAt = Support::formatRfc3339((string) $row->node_at);
} elseif ((string) $row->node_type === 'suggested') {
$suggestedAt = Support::formatRfc3339((string) $row->node_at);
} elseif ((string) $row->node_type === 'node') {
$nodes[] = Support::formatClock((string) $row->node_at);
}
}
return [
'plan_date' => (string) $advice->advice_date,
'not_before_at' => $notBeforeAt,
'suggested_at' => $suggestedAt !== '' ? $suggestedAt : $notBeforeAt,
'time_nodes' => $nodes,
'advice' => (string) $advice->advice,
'prompt_version' => (string) $advice->prompt_version,
'model' => (string) $advice->model,
'provider' => (string) $advice->provider,
];
}
private function shouldRefreshNextCache(array $suggestion, DateTimeImmutable $planDate): bool
{
$nodes = $suggestion['time_nodes'] ?? [];
if (empty($nodes)) {
return true;
}
$suggestedAt = trim((string) ($suggestion['suggested_at'] ?? ''));
if ($suggestedAt === '') {
return true;
}
$suggested = $this->parseFlexibleTime($suggestedAt, $planDate, Support::now());
$today = Support::dateOnly();
if (Support::dateOnly($planDate) == $today && $suggested <= Support::now()->add(new DateInterval('PT2M'))) {
return true;
}
return false;
}
private function normalizeNodes(array $nodes, DateTimeImmutable $planDate, DateTimeImmutable $notBeforeAt, ?array $profile): array
{
$seen = [];
$result = [];
foreach ($nodes as $node) {
try {
$nodeAt = $this->parseFlexibleTime((string) $node, $planDate, $notBeforeAt);
} catch (\Throwable $e) {
continue;
}
if ($nodeAt < $notBeforeAt) {
continue;
}
if ($profile && !empty($profile['wake_up_time']) && !empty($profile['sleep_time'])) {
$nodeAt = $this->adjustToWakeIfInSleep($nodeAt, (string) $profile['wake_up_time'], (string) $profile['sleep_time']);
}
if (Support::dateOnly($nodeAt) != Support::dateOnly($planDate)) {
continue;
}
$label = $nodeAt->format('H:i');
if (isset($seen[$label])) {
continue;
}
$seen[$label] = true;
$result[] = $label;
if (count($result) >= 6) {
break;
}
}
return $result;
}
private function adjustToWakeIfInSleep(DateTimeImmutable $time, string $wakeUpTime, string $sleepTime): DateTimeImmutable
{
$wakeMin = Support::parseHHMM($wakeUpTime);
$sleepMin = Support::parseHHMM($sleepTime);
if ($wakeMin === $sleepMin) {
return $time;
}
$minuteOfDay = ((int) $time->format('H')) * 60 + (int) $time->format('i');
$inSleep = $wakeMin < $sleepMin
? ($minuteOfDay < $wakeMin || $minuteOfDay >= $sleepMin)
: ($minuteOfDay >= $sleepMin && $minuteOfDay < $wakeMin);
if (!$inSleep) {
return $time;
}
$wakeToday = Support::dateOnly($time)->setTime(intdiv($wakeMin, 60), $wakeMin % 60);
if ($wakeToday <= $time) {
$wakeToday = $wakeToday->add(new DateInterval('P1D'));
}
return $wakeToday;
}
private function parseFlexibleTime(string $value, DateTimeImmutable $planDate, DateTimeImmutable $fallback): DateTimeImmutable
{
$text = trim($value);
if ($text === '') {
return $fallback;
}
try {
if (preg_match('/^\d{2}:\d{2}$/', $text)) {
$minutes = Support::parseHHMM($text);
return Support::dateOnly($planDate)->setTime(intdiv($minutes, 60), $minutes % 60);
}
return Support::toDateTime($text);
} catch (\Throwable $e) {
return $fallback;
}
}
private function hasAiConfig(): bool
{
return trim((string) env('AI_BASE_URL', '')) !== ''
&& trim((string) env('AI_API_KEY', '')) !== ''
&& trim((string) env('AI_MODEL', '')) !== '';
}
private function callChat(array $messages): array
{
$baseUrl = rtrim((string) env('AI_BASE_URL', ''), '/');
$apiKey = trim((string) env('AI_API_KEY', ''));
$model = trim((string) env('AI_MODEL', ''));
$timeout = max(5, (int) env('AI_TIMEOUT_SECONDS', 15));
$payload = json_encode([
'model' => $model,
'messages' => $messages,
'temperature' => 0.7,
], JSON_UNESCAPED_UNICODE);
$ch = curl_init($baseUrl . '/chat/completions');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey,
]);
$response = curl_exec($ch);
$error = curl_error($ch);
$statusCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($response === false || $response === '' || $error) {
throw new \RuntimeException('AI 请求失败', 502);
}
if ($statusCode !== 200) {
throw new \RuntimeException('AI 服务响应异常', 502);
}
$decoded = json_decode($response, true);
$content = trim((string) ($decoded['choices'][0]['message']['content'] ?? ''));
if ($content === '') {
throw new \RuntimeException('AI 返回内容为空', 502);
}
return [
'content' => $content,
'provider' => 'openai-compatible',
'model' => (string) ($decoded['model'] ?? $model),
'tokens_in' => isset($decoded['usage']['prompt_tokens']) ? (int) $decoded['usage']['prompt_tokens'] : null,
'tokens_out' => isset($decoded['usage']['completion_tokens']) ? (int) $decoded['usage']['completion_tokens'] : null,
];
}
private function extractJson(string $value): string
{
$start = strpos($value, '{');
$end = strrpos($value, '}');
if ($start === false || $end === false || $end <= $start) {
return '';
}
return substr($value, $start, $end - $start + 1);
}
}